Repository: scala-steward-org/scala-steward
Pull request #2714 (2022-09-23 07:28:47)

Add support for grouping dependency updates
## 💻 How to review this PR?

This PR was created with the idea of being reviewed commit by commit.

Each commit contains an incremental change (and its tests, if applicable) that makes it easier to review. Also some of the commits contain additional information in their description to help understand why the change was made.

<details><summary><b>I also recommend checking "Hide whitespace" when reviewing this PR!</b></summary></br>


![](https://i0.wp.com/user-images.githubusercontent.com/2503052/137387087-91cc8458-9e11-44a9-8da0-f48251ec452c.gif?ssl=1)

</details>

## 🎯 What's the goal of this PR?

This PR adds support for a long-requested feature of Scala Steward, the ability to group pull-request updates.

## ❓ How this will be done?

A new configuration value (`pullRequests.grouping`) can be added to the `.scala-steward.conf` file. This config will represent the desired PR groups.

A group will be an object with:

- `name` (mandatory): the name of the group, can be used for things like naming the branch.
- `title` (optional): if provided it will be used as the title for the PR.
- `filter` (mandatory): a non-empty list containing the filters to use to know if an update falls into this group. Filters would have this format:
  + `{version = "major" | "minor" | "patch" | "pre-release" | "build-metadata"}, group = "{group}", artifact = "{artifact}"}`.

For filters every component is optional but at least one must be provided. For grouping every update a filter like `{group = "*"}` can be provided.

<details><summary><b>Examples</b></summary></br>

```conf
pullRequests.grouping = [
  { name = "patches", title = "Patch updates", filter = [{version = "patch"}] },
  { name = "minor_major", title = "Minor/major updates", filter = [{version = "minor"}, {version = "major"}] }
]
```

☝🏼 This configuration will group updates in two PRs: one titled "Patch updates" that will contain patch version updates and another one titled "Minor/major updates" with the rest of them.

```conf
pullRequests.grouping = [
  { name = "typelevel", title = "Typelevel updates", filter = [{group = "org.typelevel"}, {group = "org.http4s"}] }
]
```

☝🏼 This configuration will group Typelevel and Http4s updates into one PR.  Since there is no other group, any update not falling into this group will follow normal behavior.

```conf
pullRequests.grouping = [
  { name = "my_libraries", filter = [{"artifact" = "my-library-*"}, {"artifact" = "my-other-library", "group" = "my-org.*"}] },
  { name = "all", title = "Not my libraries, filter = [{group = "*"] }
]
```

☝🏼 This configuration will group updates whose artifact is named `my-library` or `my-org:my-other-library` in one PR titled `my_libraries` (no title provided, so it will use `name`) and any other update under one PR titled "Not my libraries"

</details>

### 🔖 Summary of changes made

- [x] Created class for representing configuration for grouping updates pull-requests
- [x] Add method for grouping `Update` objects based on configuration.
- [x] Fork the current method to process updates:
   + [x] Updates not falling into any group will follow normal update mechanism.
   + [x] A new mechanism will be created for creating pull-requests with grouped updates. This "grouped" pull requests should contain most features from the current mechanism (list of changes, scalafix migrations, hooks...) but there are some (closing a PR ignores the version) that won't be supported.
- [x] Adding tests for new paths in the code.
- [x] Update documentation for new configuration.

alejandrohdezma: @fthomas @exoego WDYT about this approach? Should I take something into account before I proceed with the next steps?
codecov[bot]: # [Codecov](https://codecov.io/gh/scala-steward-org/scala-steward/pull/2714?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scala-steward-org) Report
Base: **80.69**% // Head: **80.82**% // Increases project coverage by **`+0.12%`** :tada:
> Coverage data is based on head [(`2452939`)](https://codecov.io/gh/scala-steward-org/scala-steward/pull/2714?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scala-steward-org) compared to base [(`b3463cd`)](https://codecov.io/gh/scala-steward-org/scala-steward/commit/b3463cdc09e1a9b90eb380f0bd078997400c9116?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scala-steward-org).
> Patch coverage: 95.65% of modified lines in pull request are covered.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main    #2714      +/-   ##
==========================================
+ Coverage   80.69%   80.82%   +0.12%     
==========================================
  Files         146      149       +3     
  Lines        2678     2701      +23     
  Branches       48       50       +2     
==========================================
+ Hits         2161     2183      +22     
- Misses        517      518       +1     
```


| [Impacted Files](https://codecov.io/gh/scala-steward-org/scala-steward/pull/2714?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scala-steward-org) | Coverage Δ | |
|---|---|---|
| [...lasteward/core/repoconfig/PullRequestsConfig.scala](https://codecov.io/gh/scala-steward-org/scala-steward/pull/2714/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scala-steward-org#diff-bW9kdWxlcy9jb3JlL3NyYy9tYWluL3NjYWxhL29yZy9zY2FsYXN0ZXdhcmQvY29yZS9yZXBvY29uZmlnL1B1bGxSZXF1ZXN0c0NvbmZpZy5zY2FsYQ==) | `91.66% <ø> (ø)` | |
| [...org/scalasteward/core/repoconfig/VersionType.scala](https://codecov.io/gh/scala-steward-org/scala-steward/pull/2714/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scala-steward-org#diff-bW9kdWxlcy9jb3JlL3NyYy9tYWluL3NjYWxhL29yZy9zY2FsYXN0ZXdhcmQvY29yZS9yZXBvY29uZmlnL1ZlcnNpb25UeXBlLnNjYWxh) | `88.88% <88.88%> (ø)` | |
| [...calasteward/core/repoconfig/PullRequestGroup.scala](https://codecov.io/gh/scala-steward-org/scala-steward/pull/2714/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scala-steward-org#diff-bW9kdWxlcy9jb3JlL3NyYy9tYWluL3NjYWxhL29yZy9zY2FsYXN0ZXdhcmQvY29yZS9yZXBvY29uZmlnL1B1bGxSZXF1ZXN0R3JvdXAuc2NhbGE=) | `100.00% <100.00%> (ø)` | |
| [...ward/core/repoconfig/PullRequestUpdateFilter.scala](https://codecov.io/gh/scala-steward-org/scala-steward/pull/2714/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scala-steward-org#diff-bW9kdWxlcy9jb3JlL3NyYy9tYWluL3NjYWxhL29yZy9zY2FsYXN0ZXdhcmQvY29yZS9yZXBvY29uZmlnL1B1bGxSZXF1ZXN0VXBkYXRlRmlsdGVyLnNjYWxh) | `100.00% <100.00%> (ø)` | |

Help us with your feedback. Take ten seconds to tell us [how you rate us](https://about.codecov.io/nps?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scala-steward-org). Have a feature suggestion? [Share it here.](https://app.codecov.io/gh/feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scala-steward-org)

</details>

[:umbrella: View full report at Codecov](https://codecov.io/gh/scala-steward-org/scala-steward/pull/2714?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scala-steward-org).   
:loudspeaker: Do you have feedback about the report comment? [Let us know in this issue](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scala-steward-org).

exoego: Great. I like the overall idea since it can cover multiple scenario (1. Grouping to reduce number of PR/CI, 2. Grouping relevant dependencies like Typelevel OSSs).


Patch:
diff --git a/build.sbt b/build.sbt
index 8d388c0a..40569e41 100644
--- a/build.sbt
+++ b/build.sbt
@@ -1,3 +1,5 @@
+import scala.util.Properties
+import scala.reflect.io.Path
 import com.typesafe.sbt.packager.docker._
 import sbtcrossproject.{CrossProject, CrossType, Platform}
 import sbtghactions.JavaSpec.Distribution.Adopt
@@ -196,6 +198,8 @@ lazy val core = myCrossProject("core")
     }.taskValue,
     run / fork := true,
     Test / fork := true,
+    Test / testOptions +=
+      Tests.Cleanup(() => Path(file(Properties.tmpDir) / "scala-steward").deleteRecursively()),
     Compile / unmanagedResourceDirectories ++= (`sbt-plugin`.jvm / Compile / unmanagedSourceDirectories).value
   )
 
diff --git a/docs/repo-specific-configuration.md b/docs/repo-specific-configuration.md
index 2fb49775..d3a0d7a3 100644
--- a/docs/repo-specific-configuration.md
+++ b/docs/repo-specific-configuration.md
@@ -32,6 +32,43 @@ You can add `<YOUR_REPO>/.scala-steward.conf` to configure how Scala Steward upd
 #pullRequests.frequency = "0 0 ? * 3" # every thursday on midnight
 pullRequests.frequency = "7 days"
 
+# pullRequests.grouping allows you to specify how Scala Steward should group
+# your updates in order to reduce the number of pull-requests.
+#
+# Updates will be placed in the first group with which they match, starting
+# from the first in the array. Those that do not match any group will follow
+# the default procedure (one PR per update).
+#
+# Each element in the array will have the following schema:
+# 
+#   - name (mandatory): the name of the group, will be used for things like naming the branch
+#   - title (optional): if provided it will be used as the title for the PR
+#   - filter (mandatory): a non-empty list containing the filters to use to know 
+#                         if an update falls into this group.
+#
+# `filter` properties would have this format:
+#   
+#    {
+#       version = "major" | "minor" | "patch" | "pre-release" | "build-metadata",
+#       group = "{group}",
+#       artifact = "{artifact}"
+#    }
+#
+# For more information on the values for the `version` filter visit https://semver.org/
+# 
+# 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.
+#
+# 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 = "all", title = "Dependency updates", "filter" = [{"group" = "*"}] }
+]
+
 # pullRequests.includeMatchedLabels allows to control which labels are added to PRs 
 # via a regex check each label is checked against.
 # Defaults to no regex (all labels are added) which is equivalent to ".*".
diff --git a/modules/core/src/main/scala/org/scalasteward/core/data/SemVer.scala b/modules/core/src/main/scala/org/scalasteward/core/data/SemVer.scala
index 60f464f1..b17d86ad 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/data/SemVer.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/data/SemVer.scala
@@ -17,6 +17,7 @@
 package org.scalasteward.core.data
 
 import cats.syntax.all._
+import io.circe.{Codec, Decoder, Encoder}
 import org.scalasteward.core.data.SemVer.Change._
 
 import scala.annotation.tailrec
@@ -42,6 +43,22 @@ object SemVer {
     case object Patch extends Change("patch")
     case object PreRelease extends Change("pre-release")
     case object BuildMetadata extends Change("build-metadata")
+
+    final private val allowed = List(Major, Minor, Patch, PreRelease, BuildMetadata)
+      .map(change => s"`${change.render}`")
+      .mkString(", ")
+
+    implicit val ChangeCodec: Codec[Change] =
+      Codec.from(Decoder[String].emap(from), Encoder[String].contramap(_.render))
+
+    def from(string: String): Either[String, Change] = string match {
+      case Major.`render`         => Right(Major)
+      case Minor.`render`         => Right(Minor)
+      case Patch.`render`         => Right(Patch)
+      case PreRelease.`render`    => Right(PreRelease)
+      case BuildMetadata.`render` => Right(BuildMetadata)
+      case other => Left(s"Invalid value for version change: $other. Allowed values are: $allowed")
+    }
   }
 
   def getChangeSpec(from: SemVer, to: SemVer): Option[Change] =
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 37a0caad..4f1d3133 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
@@ -19,13 +19,73 @@ package org.scalasteward.core.data
 import cats.Order
 import cats.implicits._
 import io.circe.Codec
+import io.circe.syntax._
 import io.circe.generic.semiauto._
-import org.scalasteward.core.data.Update.{Group, Single}
+import org.scalasteward.core.data.Update.Group
+import org.scalasteward.core.data.Update.Single
+import org.scalasteward.core.repoconfig.PullRequestGroup
 import org.scalasteward.core.util
 import org.scalasteward.core.util.Nel
 import org.scalasteward.core.util.string.MinLengthString
+import io.circe.Decoder
 
-sealed trait Update extends Product with Serializable {
+sealed trait AnUpdate {
+
+  def on[A](update: Update => A, grouped: GroupedUpdate => A): A = this match {
+    case g: GroupedUpdate => grouped(g)
+    case u: Update        => update(u)
+  }
+
+  def show: String
+
+}
+
+object AnUpdate {
+
+  implicit val AnUpdateCodec: Codec[AnUpdate] = Codec.from(
+    Decoder[GroupedUpdate].widen[AnUpdate].or(Decoder[Update].widen[AnUpdate]),
+    _.on(_.asJson, _.asJson)
+  )
+
+}
+
+final case class GroupedUpdate(name: String, title: Option[String], updates: List[Update])
+    extends AnUpdate {
+
+  override def show: String = name
+
+}
+
+object GroupedUpdate {
+
+  /**
+    * Processes the provided updates using the group configuration. Each update will only be present in the
+    * first group it falls into.
+    *
+    * Updates that do not fall into any group will be returned back in the second return parameter.
+    */
+  /**
+    * Processes the provided updates using the group configuration. Each update will only be present in the
+    * first group it falls into.
+    *
+    * Updates that do not fall into any group will be returned back in the second return parameter.
+    */
+  def from(
+      groups: List[PullRequestGroup],
+      updates: List[Update.Single]
+  ): (List[GroupedUpdate], List[Update.Single]) =
+    groups.foldLeft((List.empty[GroupedUpdate], updates)) { case ((grouped, notGrouped), group) =>
+      notGrouped.partition(group.matches) match {
+        case (Nil, rest)     => (grouped, rest)
+        case (matched, rest) => (grouped :+ GroupedUpdate(group.name, group.title, matched), rest)
+      }
+    }
+
+  implicit val GroupedUpdateCodec: Codec[GroupedUpdate] = deriveCodec
+
+}
+
+sealed trait Update extends Product with Serializable with AnUpdate {
   def crossDependencies: Nel[CrossDependency]
   def dependencies: Nel[Dependency]
   def groupId: GroupId
@@ -40,7 +100,7 @@ sealed trait Update extends Product with Serializable {
   final def nextVersion: Version =
     newerVersions.head
 
-  final def show: String = {
+  final override def show: String = {
     val artifacts = this match {
       case s: Single => s.crossDependency.showArtifactNames
       case g: Group  => g.crossDependencies.map(_.showArtifactNames).mkString_("{", ", ", "}")
diff --git a/modules/core/src/main/scala/org/scalasteward/core/data/UpdateData.scala b/modules/core/src/main/scala/org/scalasteward/core/data/UpdateData.scala
index 49bf1b2b..459cc11b 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/data/UpdateData.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/data/UpdateData.scala
@@ -23,7 +23,7 @@ import org.scalasteward.core.vcs.data.Repo
 final case class UpdateData(
     repoData: RepoData,
     fork: Repo,
-    update: Update,
+    update: AnUpdate,
     baseBranch: Branch,
     baseSha1: Sha1,
     updateBranch: Branch
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 80b4ba36..360960ae 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
@@ -17,7 +17,7 @@
 package org.scalasteward.core.git
 
 import cats.syntax.all._
-import org.scalasteward.core.data.Update
+import org.scalasteward.core.data.AnUpdate
 import org.scalasteward.core.update.show
 import org.scalasteward.core.util.Nel
 
@@ -39,17 +39,25 @@ final case class CommitMsg(
 }
 
 object CommitMsg {
-  def replaceVariables(s: String)(update: Update, baseBranch: Option[Branch]): CommitMsg = {
-    val artifactNameValue = show.oneLiner(update)
-    val nextVersionValue = update.nextVersion.value
-    val defaultValue = s"Update $artifactNameValue to $nextVersionValue" +
-      baseBranch.fold("")(branch => s" in ${branch.name}")
-    val title = s
-      .replace("${default}", defaultValue)
-      .replace("${artifactName}", artifactNameValue)
-      .replace("${currentVersion}", update.currentVersion.value)
-      .replace("${nextVersion}", nextVersionValue)
-      .replace("${branchName}", baseBranch.map(_.name).orEmpty)
-    CommitMsg(title = title)
-  }
+  def replaceVariables(s: String)(update: AnUpdate, baseBranch: Option[Branch]): CommitMsg =
+    update.on(
+      u => {
+        val artifactNameValue = show.oneLiner(u)
+        val nextVersionValue = u.nextVersion.value
+        val defaultValue = s"Update $artifactNameValue to $nextVersionValue" +
+          baseBranch.fold("")(branch => s" in ${branch.name}")
+        val title = s
+          .replace("${default}", defaultValue)
+          .replace("${artifactName}", artifactNameValue)
+          .replace("${currentVersion}", u.currentVersion.value)
+          .replace("${nextVersion}", nextVersionValue)
+          .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}")
+        )
+    )
 }
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 0f7f41aa..98b5dbd3 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
@@ -16,7 +16,7 @@
 
 package org.scalasteward.core
 
-import org.scalasteward.core.data.Update
+import org.scalasteward.core.data.{AnUpdate, GroupedUpdate, Update}
 import org.scalasteward.core.repoconfig.CommitsConfig
 import org.scalasteward.core.vcs.data.Repo
 
@@ -27,9 +27,12 @@ package object git {
 
   val updateBranchPrefix = "update"
 
-  def branchFor(update: Update, baseBranch: Option[Branch]): Branch = {
+  def branchFor(update: AnUpdate, baseBranch: Option[Branch]): Branch = {
     val base = baseBranch.fold("")(branch => s"${branch.name}/")
-    Branch(s"$updateBranchPrefix/$base${update.name}-${update.nextVersion}")
+    update match {
+      case g: GroupedUpdate => Branch(s"$updateBranchPrefix/$base${g.name}")
+      case u: Update        => Branch(s"$updateBranchPrefix/$base${u.name}-${u.nextVersion}")
+    }
   }
 
   def commitMsgFor(
diff --git a/modules/core/src/main/scala/org/scalasteward/core/nurture/NurtureAlg.scala b/modules/core/src/main/scala/org/scalasteward/core/nurture/NurtureAlg.scala
index 9db26051..e69d6fa6 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/nurture/NurtureAlg.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/nurture/NurtureAlg.scala
@@ -32,6 +32,7 @@ import org.scalasteward.core.vcs.data._
 import org.scalasteward.core.vcs.{VCSApiAlg, VCSExtraAlg, VCSRepoAlg}
 import org.scalasteward.core.{git, util, vcs}
 import org.typelevel.log4cats.Logger
+import cats.Applicative
 
 final class NurtureAlg[F[_]](config: VCSCfg)(implicit
     coursierAlg: CoursierAlg[F],
@@ -49,7 +50,9 @@ final class NurtureAlg[F[_]](config: VCSCfg)(implicit
     for {
       _ <- logger.info(s"Nurture ${data.repo.show}")
       baseBranch <- cloneAndSync(data.repo, fork)
-      _ <- updateDependencies(data, fork.repo, baseBranch, Update.groupByGroupId(updates.toList))
+      (grouped, notGrouped) = GroupedUpdate.from(data.config.pullRequests.grouping, updates.toList)
+      finalUpdates = Update.groupByGroupId(notGrouped) ++ grouped
+      _ <- updateDependencies(data, fork.repo, baseBranch, finalUpdates)
     } yield ()
 
   private def cloneAndSync(repo: Repo, fork: RepoOut): F[Branch] =
@@ -62,7 +65,7 @@ final class NurtureAlg[F[_]](config: VCSCfg)(implicit
       data: RepoData,
       fork: Repo,
       baseBranch: Branch,
-      updates: List[Update]
+      updates: List[AnUpdate]
   ): F[Unit] =
     for {
       _ <- logger.info(util.logger.showUpdates(updates))
@@ -89,12 +92,12 @@ final class NurtureAlg[F[_]](config: VCSCfg)(implicit
       head = vcs.listingBranch(config.tpe, data.fork, data.updateBranch)
       pullRequests <- vcsApiAlg.listPullRequests(data.repo, head, data.baseBranch)
       result <- pullRequests.headOption match {
-        case Some(pr) if pr.state.isClosed =>
+        case Some(pr) if pr.state.isClosed && data.update.isInstanceOf[Update] =>
           logger.info(s"PR ${pr.html_url} is closed") >>
             deleteRemoteBranch(data.repo, data.updateBranch).as(Ignored)
-        case Some(pr) =>
+        case Some(pr) if !pr.state.isClosed =>
           logger.info(s"Found PR ${pr.html_url}") >> updatePullRequest(data)
-        case None =>
+        case _ =>
           applyNewUpdate(data).flatTap {
             case Created(newPrNumber) => closeObsoletePullRequests(data, newPrNumber)
             case _                    => F.unit
@@ -114,9 +117,13 @@ final class NurtureAlg[F[_]](config: VCSCfg)(implicit
     } yield result
 
   private def closeObsoletePullRequests(data: UpdateData, newNumber: PullRequestNumber): F[Unit] =
-    pullRequestRepository
-      .getObsoleteOpenPullRequests(data.repo, data.update)
-      .flatMap(_.traverse_(oldPr => closeObsoletePullRequest(data, newNumber, oldPr)))
+    data.update.on(
+      update = pullRequestRepository
+        .getObsoleteOpenPullRequests(data.repo, _)
+        .flatMap(_.traverse_(oldPr => closeObsoletePullRequest(data, newNumber, oldPr))),
+      // We don't support closing obsolete PRs for `GroupedUpdate`s
+      grouped = _ => Applicative[F].unit
+    )
 
   private def closeObsoletePullRequest(
       data: UpdateData,
@@ -152,15 +159,20 @@ final class NurtureAlg[F[_]](config: VCSCfg)(implicit
     gitAlg.returnToCurrentBranch(data.repo) {
       val createBranch = logger.info(s"Create branch ${data.updateBranch.name}") >>
         gitAlg.createBranch(data.repo, data.updateBranch)
-      editAlg.applyUpdate(data.repoData, data.update, createBranch).flatMap { edits =>
-        val editCommits = edits.flatMap(_.commits)
-        if (editCommits.isEmpty) logger.warn("No commits created").as(Ignored)
-        else
-          gitAlg.branchesDiffer(data.repo, data.baseBranch, data.updateBranch).flatMap {
-            case true  => pushCommits(data, editCommits) >> createPullRequest(data, edits)
-            case false => logger.warn("No diff between base and update branch").as(Ignored)
-          }
-      }
+      data.update
+        .on(
+          update = editAlg.applyUpdate(data.repoData, _, createBranch),
+          grouped = _.updates.flatTraverse(editAlg.applyUpdate(data.repoData, _, createBranch))
+        )
+        .flatMap { edits =>
+          val editCommits = edits.flatMap(_.commits)
+          if (editCommits.isEmpty) logger.warn("No commits created").as(Ignored)
+          else
+            gitAlg.branchesDiffer(data.repo, data.baseBranch, data.updateBranch).flatMap {
+              case true  => pushCommits(data, editCommits) >> createPullRequest(data, edits)
+              case false => logger.warn("No diff between base and update branch").as(Ignored)
+            }
+        }
     }
 
   private def pushCommits(data: UpdateData, commits: List[Commit]): F[ProcessResult] =
@@ -171,28 +183,51 @@ final class NurtureAlg[F[_]](config: VCSCfg)(implicit
         _ <- gitAlg.push(data.repo, data.updateBranch)
       } yield Updated
 
+  private def dependenciesUpdatedWithNextAndCurrentVersion(
+      update: AnUpdate
+  ): List[(Version, Dependency)] =
+    update.on(
+      u => u.dependencies.map(_.copy(version = u.nextVersion)).tupleLeft(u.currentVersion).toList,
+      _.updates.flatMap(dependenciesUpdatedWithNextAndCurrentVersion(_))
+    )
+
   private def createPullRequest(data: UpdateData, edits: List[EditAttempt]): F[ProcessResult] =
     for {
       _ <- logger.info(s"Create PR ${data.updateBranch.name}")
-      dependenciesWithNextVersion =
-        data.update.dependencies.map(_.copy(version = data.update.nextVersion)).toList
+      dependenciesWithNextVersion = dependenciesUpdatedWithNextAndCurrentVersion(data.update)
       resolvers = data.repoData.cache.dependencyInfos.flatMap(_.resolvers)
-      artifactIdToUrl <-
-        coursierAlg.getArtifactIdUrlMapping(Scope(dependenciesWithNextVersion, resolvers))
-      existingArtifactUrlsList <- artifactIdToUrl.toList.filterA(a => urlChecker.exists(a._2))
+      dependencyScope = Scope(
+        value = dependenciesWithNextVersion.map { case (_, dependency) => dependency },
+        resolvers = resolvers
+      )
+      artifactIdToUrl <- coursierAlg.getArtifactIdUrlMapping(dependencyScope)
+      existingArtifactUrlsList <- artifactIdToUrl.toList.filterA { case (_, uri) =>
+        urlChecker.exists(uri)
+      }
       existingArtifactUrlsMap = existingArtifactUrlsList.toMap
-      releaseRelatedUrls <-
-        existingArtifactUrlsMap
-          .get(data.update.mainArtifactId)
-          .traverse(vcsExtraAlg.getReleaseRelatedUrls(_, data.update))
-      filesWithOldVersion <- gitAlg.findFilesContaining(data.repo, data.update.currentVersion.value)
+      releaseRelatedUrls <- dependenciesWithNextVersion.flatTraverse {
+        case (currentVersion, dependency) =>
+          existingArtifactUrlsMap
+            .get(dependency.artifactId.name)
+            .toList
+            .traverse(uri =>
+              vcsExtraAlg
+                .getReleaseRelatedUrls(uri, currentVersion, dependency.version)
+                .tupleLeft(dependency.artifactId.name)
+            )
+      }
+      filesWithOldVersion <-
+        data.update
+          .on(u => List(u.currentVersion.value), _.updates.map(_.currentVersion.value))
+          .flatTraverse(gitAlg.findFilesContaining(data.repo, _))
+          .map(_.distinct)
       branchName = vcs.createBranch(config.tpe, data.fork, data.updateBranch)
       requestData = NewPullRequestData.from(
         data,
         branchName,
         edits,
         existingArtifactUrlsMap,
-        releaseRelatedUrls.getOrElse(List.empty),
+        releaseRelatedUrls.toMap,
         filesWithOldVersion,
         data.repoData.config.pullRequests.includeMatchedLabels
       )
@@ -250,9 +285,13 @@ final class NurtureAlg[F[_]](config: VCSCfg)(implicit
       )
       maybeRevertCommit <- gitAlg.revertChanges(data.repo, data.baseBranch)
       maybeMergeCommit <- gitAlg.mergeTheirs(data.repo, data.baseBranch)
-      edits <- editAlg.applyUpdate(data.repoData, data.update)
+      edits <- data.update.on(
+        update = editAlg.applyUpdate(data.repoData, _),
+        grouped = _.updates.flatTraverse(editAlg.applyUpdate(data.repoData, _))
+      )
       editCommits = edits.flatMap(_.commits)
       commits = maybeRevertCommit.toList ++ maybeMergeCommit.toList ++ editCommits
       result <- pushCommits(data, commits)
     } yield result
+
 }
diff --git a/modules/core/src/main/scala/org/scalasteward/core/nurture/PullRequestData.scala b/modules/core/src/main/scala/org/scalasteward/core/nurture/PullRequestData.scala
index 15525283..4d5aa230 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/nurture/PullRequestData.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/nurture/PullRequestData.scala
@@ -17,14 +17,14 @@
 package org.scalasteward.core.nurture
 
 import org.http4s.Uri
-import org.scalasteward.core.data.Update
+import org.scalasteward.core.data.AnUpdate
 import org.scalasteward.core.git.{Branch, Sha1}
 import org.scalasteward.core.vcs.data.{PullRequestNumber, PullRequestState}
 
 final case class PullRequestData[F[_]](
     url: Uri,
     baseSha1: Sha1,
-    update: Update,
+    update: AnUpdate,
     state: PullRequestState,
     number: F[PullRequestNumber],
     updateBranch: F[Branch]
diff --git a/modules/core/src/main/scala/org/scalasteward/core/nurture/PullRequestRepository.scala b/modules/core/src/main/scala/org/scalasteward/core/nurture/PullRequestRepository.scala
index b82a744a..073687d1 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/nurture/PullRequestRepository.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/nurture/PullRequestRepository.scala
@@ -21,7 +21,7 @@ import cats.{Id, Monad}
 import io.circe.Codec
 import io.circe.generic.semiauto.deriveCodec
 import org.http4s.Uri
-import org.scalasteward.core.data.{CrossDependency, GroupId, Update, Version}
+import org.scalasteward.core.data.{AnUpdate, CrossDependency, GroupId, Update, Version}
 import org.scalasteward.core.git
 import org.scalasteward.core.git.{Branch, Sha1}
 import org.scalasteward.core.nurture.PullRequestRepository.Entry
@@ -69,21 +69,14 @@ final class PullRequestRepository[F[_]](kvStore: KeyValueStore[F, Repo, Map[Uri,
   def getObsoleteOpenPullRequests(repo: Repo, update: Update): F[List[PullRequestData[Id]]] =
     kvStore.getOrElse(repo, Map.empty).map {
       _.collect {
-        case (url, entry)
-            if entry.state === PullRequestState.Open &&
-              entry.update.withNewerVersions(update.newerVersions) === update &&
-              entry.update.nextVersion < update.nextVersion =>
+        case (url, Entry(baseSha1, u: Update, state, _, number, updateBranch))
+            if state === PullRequestState.Open &&
+              u.withNewerVersions(update.newerVersions) === update &&
+              u.nextVersion < update.nextVersion =>
           for {
-            number <- entry.number
-            updateBranch = entry.updateBranch.getOrElse(git.branchFor(entry.update, repo.branch))
-          } yield PullRequestData[Id](
-            url,
-            entry.baseSha1,
-            entry.update,
-            entry.state,
-            number,
-            updateBranch
-          )
+            number <- number
+            branch = updateBranch.getOrElse(git.branchFor(u, repo.branch))
+          } yield PullRequestData[Id](url, baseSha1, u, state, number, branch)
       }.flatten.toList.sortBy(_.number.value)
     }
 
@@ -93,9 +86,10 @@ final class PullRequestRepository[F[_]](kvStore: KeyValueStore[F, Repo, Map[Uri,
       newVersion: Version
   ): F[Option[PullRequestData[Option]]] =
     kvStore.getOrElse(repo, Map.empty).map {
-      _.filter { case (_, entry) =>
-        UpdateAlg.isUpdateFor(entry.update, crossDependency) &&
-        entry.update.nextVersion === newVersion
+      _.filter {
+        case (_, Entry(_, u: Update, _, _, _, _)) =>
+          UpdateAlg.isUpdateFor(u, crossDependency) && u.nextVersion === newVersion
+        case _ => false
       }
         .maxByOption { case (_, entry) => entry.entryCreatedAt.millis }
         .map { case (url, entry) =>
@@ -118,9 +112,12 @@ final class PullRequestRepository[F[_]](kvStore: KeyValueStore[F, Repo, Map[Uri,
       case None => Map.empty
       case Some(pullRequests) =>
         pullRequests.values
-          .groupBy(entry => (entry.update.groupId, entry.update.mainArtifactId))
+          .collect { case Entry(_, u: Update, _, entryCreatedAt, _, _) =>
+            (u.groupId, u.mainArtifactId, entryCreatedAt)
+          }
+          .groupBy { case (groupId, mainArtifactId, _) => (groupId, mainArtifactId) }
           .view
-          .mapValues(_.map(_.entryCreatedAt).max)
+          .mapValues(_.map { case (_, _, createdAt) => createdAt }.max)
           .toMap
     }
 }
@@ -128,7 +125,7 @@ final class PullRequestRepository[F[_]](kvStore: KeyValueStore[F, Repo, Map[Uri,
 object PullRequestRepository {
   final case class Entry(
       baseSha1: Sha1,
-      update: Update,
+      update: AnUpdate,
       state: PullRequestState,
       entryCreatedAt: Timestamp,
       number: Option[PullRequestNumber],
diff --git a/modules/core/src/main/scala/org/scalasteward/core/repoconfig/GroupRepoConfig.scala b/modules/core/src/main/scala/org/scalasteward/core/repoconfig/GroupRepoConfig.scala
index ab17387f..0506825f 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/repoconfig/GroupRepoConfig.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/repoconfig/GroupRepoConfig.scala
@@ -19,7 +19,8 @@ package org.scalasteward.core.repoconfig
 import io.circe.Codec
 import io.circe.generic.extras.Configuration
 import io.circe.generic.semiauto.deriveCodec
-import org.scalasteward.core.data.Update
+import org.scalasteward.core.data.{AnUpdate, Update}
+import org.scalasteward.core.util.string.indentLines
 
 final case class GroupRepoConfig(
     pullRequests: PullRequestsConfig = PullRequestsConfig(),
@@ -33,17 +34,27 @@ object GroupRepoConfig {
   implicit val groupPullConfigCodec: Codec[GroupRepoConfig] =
     deriveCodec
 
-  def configToSlowDownUpdatesFrequency(update: Update): String =
-    update match {
+  def configToSlowDownUpdatesFrequency(update: AnUpdate): String = {
+    val forUpdate: Update => String = {
       case s: Update.Single =>
-        s"""dependencyOverrides = [{
+        s"""{
            |  pullRequests = { frequency = "@monthly" },
            |  dependency = { groupId = "${s.groupId}", artifactId = "${s.artifactId.name}" }
-           |}]""".stripMargin
+           |}""".stripMargin
       case g: Update.Group =>
-        s"""dependencyOverrides = [{
+        s"""{
            |  pullRequests = { frequency = "@monthly" },
            |  dependency = { groupId = "${g.groupId}" }
-           |}]""".stripMargin
+           |}""".stripMargin
     }
+
+    update.on(
+      update = u => s"dependencyOverrides = [${forUpdate(u)}]",
+      grouped = _.updates
+        .map(forUpdate(_))
+        .map(_.linesIterator.toList)
+        .map(indentLines(_))
+        .mkString("dependencyOverrides = [\n", ",\n", "\n]")
+    )
+  }
 }
diff --git a/modules/core/src/main/scala/org/scalasteward/core/repoconfig/PullRequestGroup.scala b/modules/core/src/main/scala/org/scalasteward/core/repoconfig/PullRequestGroup.scala
new file mode 100644
index 00000000..5447dbdf
--- /dev/null
+++ b/modules/core/src/main/scala/org/scalasteward/core/repoconfig/PullRequestGroup.scala
@@ -0,0 +1,49 @@
+/*
+ * Copyright 2018-2022 Scala Steward contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.scalasteward.core.repoconfig
+
+import cats.Eq
+import io.circe.{Decoder, Encoder}
+import io.circe.generic.semiauto._
+import cats.data.NonEmptyList
+import org.scalasteward.core.data.Update
+
+case class PullRequestGroup(
+    name: String,
+    title: Option[String] = None,
+    filter: NonEmptyList[PullRequestUpdateFilter]
+) {
+
+  /**
+    * Returns `true` if an update falls into this group; returns `false` otherwise.
+    */
+  def matches(update: Update.Single): Boolean = filter.exists(_.matches(update))
+
+}
+
+object PullRequestGroup {
+
+  implicit val pullRequestGroupEq: Eq[PullRequestGroup] =
+    Eq.fromUniversalEquals
+
+  implicit val pullRequestGroupDecoder: Decoder[PullRequestGroup] =
+    deriveDecoder
+
+  implicit val pullRequestGroupEncoder: Encoder[PullRequestGroup] =
+    deriveEncoder
+
+}
diff --git a/modules/core/src/main/scala/org/scalasteward/core/repoconfig/PullRequestUpdateFilter.scala b/modules/core/src/main/scala/org/scalasteward/core/repoconfig/PullRequestUpdateFilter.scala
new file mode 100644
index 00000000..d4c4170a
--- /dev/null
+++ b/modules/core/src/main/scala/org/scalasteward/core/repoconfig/PullRequestUpdateFilter.scala
@@ -0,0 +1,87 @@
+/*
+ * Copyright 2018-2022 Scala Steward contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.scalasteward.core.repoconfig
+
+import cats.Eq
+import cats.syntax.all._
+import io.circe._
+import io.circe.syntax._
+import org.scalasteward.core.data.{SemVer, Update}
+
+import scala.util.matching.Regex
+
+final case class PullRequestUpdateFilter private (
+    group: Option[String] = None,
+    artifact: Option[String] = None,
+    version: Option[SemVer.Change] = None
+) {
+
+  /**
+    * Returns `true` if an update falls into this filter; returns `false` otherwise.
+    */
+  def matches(update: Update.Single): Boolean =
+    groupRegex.forall(_.matches(update.groupId.value)) &&
+      artifactRegex.forall(_.matches(update.mainArtifactId)) &&
+      version.forall(isMatchedVersion(_, update))
+
+  private lazy val groupRegex = group.map(wildcardRegex)
+  private lazy val artifactRegex = artifact.map(wildcardRegex)
+
+  private def wildcardRegex(groupOrArtifact: String) = {
+    val pattern = Regex.quote(groupOrArtifact).replaceAll("\\*", "\\\\E.+\\\\Q")
+    new Regex(pattern)
+  }
+
+  private def isMatchedVersion(versionType: SemVer.Change, update: Update.Single): Boolean =
+    (SemVer.parse(update.currentVersion.value), SemVer.parse(update.nextVersion.value)).tupled
+      .flatMap { case (current, next) => SemVer.getChangeEarly(current, next) }
+      .map(_.render === versionType.render)
+      .getOrElse(false)
+
+}
+
+object PullRequestUpdateFilter {
+
+  def apply(
+      group: Option[String] = None,
+      artifact: Option[String] = None,
+      version: Option[SemVer.Change] = None
+  ): Either[String, PullRequestUpdateFilter] =
+    if (group.isEmpty && artifact.isEmpty && version.isEmpty)
+      Left("At least one predicate should be added to the filter")
+    else Right(new PullRequestUpdateFilter(group, artifact, version))
+
+  implicit val pullRequestUpdateDecoder: Decoder[PullRequestUpdateFilter] = { cursor =>
+    for {
+      group <- cursor.get[Option[String]]("group")
+      artifact <- cursor.get[Option[String]]("artifact")
+      version <- cursor.get[Option[SemVer.Change]]("version")
+      filter <- apply(group, artifact, version).leftMap(DecodingFailure(_, Nil))
+    } yield filter
+  }
+
+  implicit val pullRequestUpdateFilterEq: Eq[PullRequestUpdateFilter] =
+    Eq.fromUniversalEquals
+
+  implicit val pullRequestUpdateFilterEncoder: Encoder[PullRequestUpdateFilter] = filter =>
+    Json.obj(
+      "group" := filter.group,
+      "artifact" := filter.artifact,
+      "version" := filter.version
+    )
+
+}
diff --git a/modules/core/src/main/scala/org/scalasteward/core/repoconfig/PullRequestsConfig.scala b/modules/core/src/main/scala/org/scalasteward/core/repoconfig/PullRequestsConfig.scala
index 8de1bf4e..707cab49 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/repoconfig/PullRequestsConfig.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/repoconfig/PullRequestsConfig.scala
@@ -27,6 +27,7 @@ import scala.util.matching.Regex
 
 final case class PullRequestsConfig(
     frequency: Option[PullRequestFrequency] = None,
+    grouping: List[PullRequestGroup] = Nil,
     includeMatchedLabels: Option[Regex] = None
 ) {
   def frequencyOrDefault: PullRequestFrequency =
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 00007b79..df8f27a2 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
@@ -20,7 +20,7 @@ import better.files.File
 import cats.MonadThrow
 import cats.syntax.all._
 import io.circe.config.parser
-import org.scalasteward.core.data.Update
+import org.scalasteward.core.data.{AnUpdate, Update}
 import org.scalasteward.core.io.{FileAlg, WorkspaceAlg}
 import org.scalasteward.core.repoconfig.RepoConfigAlg._
 import org.scalasteward.core.vcs.data.Repo
@@ -60,11 +60,20 @@ object RepoConfigAlg {
   def parseRepoConfig(input: String): Either[io.circe.Error, RepoConfig] =
     parser.decode[RepoConfig](input)
 
-  def configToIgnoreFurtherUpdates(update: Update): String =
-    update match {
+  def configToIgnoreFurtherUpdates(update: AnUpdate): String = {
+    val forUpdate: Update => String = {
       case s: Update.Single =>
-        s"""updates.ignore = [ { groupId = "${s.groupId}", artifactId = "${s.artifactId.name}" } ]"""
+        s"""{ groupId = "${s.groupId}", artifactId = "${s.artifactId.name}" }""".stripMargin
       case g: Update.Group =>
-        s"""updates.ignore = [ { groupId = "${g.groupId}" } ]"""
+        s"""{ groupId = "${g.groupId}" }""".stripMargin
     }
+
+    update.on(
+      update = u => s"updates.ignore = [ ${forUpdate(u)} ]",
+      grouped = g =>
+        g.updates
+          .map("  " + forUpdate(_))
+          .mkString("updates.ignore = [\n", ",\n", "\n]")
+    )
+  }
 }
diff --git a/modules/core/src/main/scala/org/scalasteward/core/util/logger.scala b/modules/core/src/main/scala/org/scalasteward/core/util/logger.scala
index a2860d9f..4ffde31b 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/util/logger.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/util/logger.scala
@@ -17,8 +17,10 @@
 package org.scalasteward.core.util
 
 import cats.syntax.all._
-import cats.{Foldable, Functor, Monad, MonadThrow}
+import cats.{Monad, MonadThrow}
+import org.scalasteward.core.data.GroupedUpdate
 import org.scalasteward.core.data.Update
+import org.scalasteward.core.data.AnUpdate
 import org.typelevel.log4cats.Logger
 import scala.concurrent.duration.FiniteDuration
 
@@ -66,8 +68,13 @@ object logger {
     }
   }
 
-  def showUpdates[F[_]: Foldable: Functor](updates: F[Update]): String = {
-    val list = string.indentLines(updates.map(_.show))
+  def showUpdates(allUpdates: List[AnUpdate]): String = {
+    val updates = allUpdates.flatMap {
+      case g: GroupedUpdate => g.updates.map(_.show)
+      case u: Update        => List(u.show)
+    }
+
+    val list = string.indentLines(updates)
     updates.size match {
       case 0 => s"Found 0 updates"
       case 1 => s"Found 1 update:\n$list"
diff --git a/modules/core/src/main/scala/org/scalasteward/core/vcs/VCSExtraAlg.scala b/modules/core/src/main/scala/org/scalasteward/core/vcs/VCSExtraAlg.scala
index b7fc0167..842b957e 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/vcs/VCSExtraAlg.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/vcs/VCSExtraAlg.scala
@@ -20,12 +20,16 @@ import cats.Monad
 import cats.syntax.all._
 import org.http4s.Uri
 import org.scalasteward.core.application.Config.VCSCfg
-import org.scalasteward.core.data.{ReleaseRelatedUrl, Update}
+import org.scalasteward.core.data.{ReleaseRelatedUrl, Version}
 import org.scalasteward.core.util.UrlChecker
 import org.scalasteward.core.vcs
 
 trait VCSExtraAlg[F[_]] {
-  def getReleaseRelatedUrls(repoUrl: Uri, update: Update): F[List[ReleaseRelatedUrl]]
+  def getReleaseRelatedUrls(
+      repoUrl: Uri,
+      currentVersion: Version,
+      nextVersion: Version
+  ): F[List[ReleaseRelatedUrl]]
 }
 
 object VCSExtraAlg {
@@ -34,9 +38,19 @@ object VCSExtraAlg {
       F: Monad[F]
   ): VCSExtraAlg[F] =
     new VCSExtraAlg[F] {
-      override def getReleaseRelatedUrls(repoUrl: Uri, update: Update): F[List[ReleaseRelatedUrl]] =
+      override def getReleaseRelatedUrls(
+          repoUrl: Uri,
+          currentVersion: Version,
+          nextVersion: Version
+      ): F[List[ReleaseRelatedUrl]] =
         vcs
-          .possibleReleaseRelatedUrls(config.tpe, config.apiHost, repoUrl, update)
+          .possibleReleaseRelatedUrls(
+            config.tpe,
+            config.apiHost,
+            repoUrl,
+            currentVersion,
+            nextVersion
+          )
           .filterA(releaseRelatedUrl => urlChecker.exists(releaseRelatedUrl.url))
     }
 }
diff --git a/modules/core/src/main/scala/org/scalasteward/core/vcs/data/NewPullRequestData.scala b/modules/core/src/main/scala/org/scalasteward/core/vcs/data/NewPullRequestData.scala
index 793bd372..27ec47bc 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/vcs/data/NewPullRequestData.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/vcs/data/NewPullRequestData.scala
@@ -43,15 +43,14 @@ object NewPullRequestData {
     deriveEncoder
 
   def bodyFor(
-      update: Update,
+      update: AnUpdate,
       edits: List[EditAttempt],
       artifactIdToUrl: Map[String, Uri],
-      releaseRelatedUrls: List[ReleaseRelatedUrl],
+      releaseRelatedUrls: Map[String, List[ReleaseRelatedUrl]],
       filesWithOldVersion: List[String],
       configParsingError: Option[String],
       labels: List[String]
   ): String = {
-    val artifacts = artifactsWithOptionalUrl(update, artifactIdToUrl)
     val migrations = edits.collect { case scalafixEdit: ScalafixEdit => scalafixEdit }
     val appliedMigrations = migrationNote(migrations)
     val oldVersionDetails = oldVersionNote(filesWithOldVersion, update)
@@ -62,12 +61,40 @@ object NewPullRequestData {
       configParsingError.map(configParsingErrorDetails)
     ).flatten
 
-    s"""|Updates $artifacts ${fromTo(update)}.
-        |${releaseNote(releaseRelatedUrls).getOrElse("")}
+    val updatesText = update.on(
+      update = u => {
+        val artifacts = artifactsWithOptionalUrl(u, artifactIdToUrl)
+
+        val relatedUrls = releaseRelatedUrls.get(u.mainArtifactId).getOrElse(Nil)
+
+        s"""|Updates $artifacts ${fromTo(u)}.
+            |${releaseNote(relatedUrls).getOrElse("")}""".stripMargin.trim
+      },
+      grouped = g => {
+        val artifacts = g.updates
+          .fproduct(u => releaseRelatedUrls.get(u.mainArtifactId).orEmpty)
+          .map { case (u, relatedUrls) =>
+            s"* ${artifactsWithOptionalUrl(u, artifactIdToUrl)} ${fromTo(u)}" +
+              releaseNote(relatedUrls).map(urls => s"\n  + $urls").getOrElse("")
+          }
+          .mkString_("\n", "\n", "\n")
+
+        s"""|Updates:
+            |$artifacts""".stripMargin.trim
+      }
+    )
+
+    val skipVersionMessage = update.on(
+      _ => "If you'd like to skip this version, you can just close this PR. ",
+      _ => ""
+    )
+
+    s"""|$updatesText
+        |
         |
         |I'll automatically update this PR to resolve conflicts as long as you don't change it yourself.
         |
-        |If you'd like to skip this version, you can just close this PR. If you have any feedback, just mention me in the comments below.
+        |${skipVersionMessage}If you have any feedback, just mention me in the comments below.
         |
         |Configure Scala Steward for your repository with a [`${RepoConfigAlg.repoConfigBasename}`](${org.scalasteward.core.BuildInfo.gitHubUrl}/blob/${org.scalasteward.core.BuildInfo.gitHeadCommit}/docs/repo-specific-configuration.md) file.
         |
@@ -120,11 +147,16 @@ object NewPullRequestData {
       case None      => s"$groupId:$artifactId"
     }
 
-  def oldVersionNote(files: List[String], update: Update): Option[Details] =
+  def oldVersionNote(files: List[String], update: AnUpdate): Option[Details] =
     Option.when(files.nonEmpty) {
+      val (number, numberWithVersion) = update.on(
+        update = u => ("number", s"number (${u.currentVersion})"),
+        grouped = _ => ("numbers", "numbers")
+      )
+
       Details(
-        "Files still referring to the old version number",
-        s"""The following files still refer to the old version number (${update.currentVersion}).
+        s"Files still referring to the old version $number",
+        s"""The following files still refer to the old version $numberWithVersion.
            |You might want to review and update them manually.
            |```
            |${files.mkString("\n")}
@@ -133,19 +165,31 @@ object NewPullRequestData {
       )
     }
 
-  def adjustFutureUpdates(update: Update): Details =
-    Details(
-      "Adjust future updates",
-      s"""|Add this to your `${RepoConfigAlg.repoConfigBasename}` file to ignore future updates of this dependency:
-          |```
-          |${RepoConfigAlg.configToIgnoreFurtherUpdates(update)}
-          |```
-          |Or, add this to slow down future updates of this dependency:
-          |```
-          |${GroupRepoConfig.configToSlowDownUpdatesFrequency(update)}
-          |```
-          |""".stripMargin.trim
+  def adjustFutureUpdates(update: AnUpdate): Details = Details(
+    "Adjust future updates",
+    update.on(
+      update = u =>
+        s"""|Add this to your `${RepoConfigAlg.repoConfigBasename}` file to ignore future updates of this dependency:
+            |```
+            |${RepoConfigAlg.configToIgnoreFurtherUpdates(u)}
+            |```
+            |Or, add this to slow down future updates of this dependency:
+            |```
+            |${GroupRepoConfig.configToSlowDownUpdatesFrequency(u)}
+            |```
+            |""".stripMargin.trim,
+      grouped = g =>
+        s"""|Add these to your `${RepoConfigAlg.repoConfigBasename}` file to ignore future updates of these dependencies:
+            |```
+            |${RepoConfigAlg.configToIgnoreFurtherUpdates(g)}
+            |```
+            |Or, add these to slow down future updates of these dependencies:
+            |```
+            |${GroupRepoConfig.configToSlowDownUpdatesFrequency(g)}
+            |```
+            |""".stripMargin.trim
     )
+  )
 
   def configParsingErrorDetails(error: String): Details =
     Details(
@@ -182,7 +226,7 @@ object NewPullRequestData {
       branchName: String,
       edits: List[EditAttempt] = List.empty,
       artifactIdToUrl: Map[String, Uri] = Map.empty,
-      releaseRelatedUrls: List[ReleaseRelatedUrl] = List.empty,
+      releaseRelatedUrls: Map[String, List[ReleaseRelatedUrl]] = Map.empty,
       filesWithOldVersion: List[String] = List.empty,
       includeMatchedLabels: Option[Regex] = None
   ): NewPullRequestData = {
@@ -209,20 +253,24 @@ object NewPullRequestData {
     )
   }
 
-  def updateType(update: Update): String = {
-    val dependencies = update.dependencies
-    if (dependencies.forall(_.configurations.contains("test")))
-      "test-library-update"
-    else if (dependencies.forall(_.configurations.contains("scalafix-rule")))
-      "scalafix-rule-update"
-    else if (dependencies.forall(_.sbtVersion.isDefined))
-      "sbt-plugin-update"
-    else
-      "library-update"
+  def updateType(anUpdate: AnUpdate): List[String] = {
+    def forUpdate(update: Update) = {
+      val dependencies = update.dependencies
+      if (dependencies.forall(_.configurations.contains("test")))
+        "test-library-update"
+      else if (dependencies.forall(_.configurations.contains("scalafix-rule")))
+        "scalafix-rule-update"
+      else if (dependencies.forall(_.sbtVersion.isDefined))
+        "sbt-plugin-update"
+      else
+        "library-update"
+    }
+
+    anUpdate.on(u => List(forUpdate(u)), _.updates.map(forUpdate).distinct)
   }
 
   def labelsFor(
-      update: Update,
+      update: AnUpdate,
       edits: List[EditAttempt],
       filesWithOldVersion: List[String],
       includeMatchedLabels: Option[Regex]
@@ -232,19 +280,26 @@ object NewPullRequestData {
       case n if n <= 1 => s"$n"
       case n           => s"n:$n"
     })
-    val semVerVersions =
-      (SemVer.parse(update.currentVersion.value), SemVer.parse(update.nextVersion.value)).tupled
-    val earlySemVerLabel = semVerVersions.flatMap { case (curr, next) =>
-      SemVer.getChangeEarly(curr, next).map(c => s"early-semver-${c.render}")
-    }
-    val semVerSpecLabel = semVerVersions.flatMap { case (curr, next) =>
-      SemVer.getChangeSpec(curr, next).map(c => s"semver-spec-${c.render}")
+
+    def semverForUpdate(u: Update): List[String] = {
+      val semVerVersions =
+        (SemVer.parse(u.currentVersion.value), SemVer.parse(u.nextVersion.value)).tupled
+      val earlySemVerLabel = semVerVersions.flatMap { case (curr, next) =>
+        SemVer.getChangeEarly(curr, next).map(c => s"early-semver-${c.render}")
+      }
+      val semVerSpecLabel = semVerVersions.flatMap { case (curr, next) =>
+        SemVer.getChangeSpec(curr, next).map(c => s"semver-spec-${c.render}")
+      }
+      List(earlySemVerLabel, semVerSpecLabel).flatten
     }
+    val semver: List[String] =
+      update.on(u => semverForUpdate(u), _.updates.flatMap(semverForUpdate(_)).distinct)
+
     val scalafixLabel = edits.collectFirst { case _: ScalafixEdit => "scalafix-migrations" }
     val oldVersionLabel = Option.when(filesWithOldVersion.nonEmpty)("old-version-remains")
 
-    val allLabels = updateType(update) ::
-      List(earlySemVerLabel, semVerSpecLabel, scalafixLabel, oldVersionLabel).flatten ++
+    val allLabels = updateType(update) ++
+      semver ++ List(scalafixLabel, oldVersionLabel).flatten ++
       List(commitCountLabel)
 
     allLabels.filter(label => includeMatchedLabels.fold(true)(_.matches(label)))
diff --git a/modules/core/src/main/scala/org/scalasteward/core/vcs/package.scala b/modules/core/src/main/scala/org/scalasteward/core/vcs/package.scala
index a0763251..f0f06e96 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/vcs/package.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/vcs/package.scala
@@ -19,7 +19,7 @@ package org.scalasteward.core
 import cats.syntax.all._
 import org.http4s.Uri
 import org.scalasteward.core.data.ReleaseRelatedUrl.VersionDiff
-import org.scalasteward.core.data.{ReleaseRelatedUrl, Update, Version}
+import org.scalasteward.core.data.{ReleaseRelatedUrl, Version}
 import org.scalasteward.core.git.Branch
 import org.scalasteward.core.vcs.VCSType.{Bitbucket, BitbucketServer, GitHub, GitLab}
 import org.scalasteward.core.vcs.data.Repo
@@ -87,36 +87,32 @@ package object vcs {
       vcsType: VCSType,
       vcsUri: Uri,
       repoUrl: Uri,
-      update: Update
-  ): List[VersionDiff] = {
-    val from = update.currentVersion
-    val to = update.nextVersion
-
-    extractRepoVCSType(vcsType, vcsUri, repoUrl)
-      .map {
-        case GitHub | GitLab =>
-          possibleTags(from).zip(possibleTags(to)).map { case (from1, to1) =>
-            VersionDiff(repoUrl / "compare" / s"$from1...$to1")
-          }
-        case Bitbucket | BitbucketServer =>
-          possibleTags(from).zip(possibleTags(to)).map { case (from1, to1) =>
-            VersionDiff((repoUrl / "compare" / s"$to1..$from1").withFragment("diff"))
-          }
-      }
-      .getOrElse(List.empty)
-  }
+      currentVersion: Version,
+      nextVersion: Version
+  ): List[VersionDiff] =
+    extractRepoVCSType(vcsType, vcsUri, repoUrl).map {
+      case GitHub | GitLab =>
+        possibleTags(currentVersion).zip(possibleTags(nextVersion)).map { case (from1, to1) =>
+          VersionDiff(repoUrl / "compare" / s"$from1...$to1")
+        }
+      case Bitbucket | BitbucketServer =>
+        possibleTags(currentVersion).zip(possibleTags(nextVersion)).map { case (from1, to1) =>
+          VersionDiff((repoUrl / "compare" / s"$to1..$from1").withFragment("diff"))
+        }
+    }.orEmpty
 
   def possibleReleaseRelatedUrls(
       vcsType: VCSType,
       vcsUri: Uri,
       repoUrl: Uri,
-      update: Update
+      currentVersion: Version,
+      nextVersion: Version
   ): List[ReleaseRelatedUrl] = {
     val repoVCSType = extractRepoVCSType(vcsType, vcsUri, repoUrl)
 
     val github = repoVCSType
       .collect { case GitHub =>
-        possibleTags(update.nextVersion).map(tag =>
+        possibleTags(nextVersion).map(tag =>
           ReleaseRelatedUrl.GitHubReleaseNotes(repoUrl / "releases" / "tag" / tag)
         )
       }
@@ -139,7 +135,7 @@ package object vcs {
       files(possibleReleaseNotesFilenames).map(ReleaseRelatedUrl.CustomReleaseNotes)
 
     github ++ customReleaseNotes ++ customChangelog ++
-      possibleCompareUrls(vcsType, vcsUri, repoUrl, update)
+      possibleCompareUrls(vcsType, vcsUri, repoUrl, currentVersion, nextVersion)
   }
 
   private def possibleFilenames(baseNames: List[String]): List[String] = {


Test patch:
diff --git a/modules/core/src/test/scala/org/scalasteward/core/data/GroupedUpdateTest.scala b/modules/core/src/test/scala/org/scalasteward/core/data/GroupedUpdateTest.scala
new file mode 100644
index 00000000..5c876413
--- /dev/null
+++ b/modules/core/src/test/scala/org/scalasteward/core/data/GroupedUpdateTest.scala
@@ -0,0 +1,565 @@
+package org.scalasteward.core.data
+
+import cats.data.NonEmptyList
+import cats.implicits.catsSyntaxOptionId
+import org.scalasteward.core.TestSyntax._
+import munit.FunSuite
+import org.scalasteward.core.repoconfig.{PullRequestGroup, PullRequestUpdateFilter}
+
+class GroupedUpdateTest extends FunSuite {
+  val updateSingleSpecs2Core: Update.Single =
+    ("org.specs2".g % "specs2-core".a % "3.9.3" %> "3.9.5").single
+
+  val updateSingleSpecs2Scalacheck: Update.Single =
+    ("org.specs2".g % "specs2-scalacheck".a % "3.9.3" %> "3.9.5").single
+
+  val updateSingleTypelevelAlgebra: Update.Single =
+    ("org.typelevel".g % "algebra".a % "3.9.4" %> "3.9.5").single
+
+  val updateSingleCirceCore: Update.Single =
+    ("circe".g % "core".a % "1.4.2" %> "1.5.0").single
+
+  test(
+    "GroupedUpdate.from: Do not group a Update.Single if the groupId does not match the filter"
+  ) {
+    val pullRequestGroupSpecs2: PullRequestGroup = PullRequestGroup(
+      "specs2",
+      Some("update specs2"),
+      NonEmptyList.one(
+        PullRequestUpdateFilter("org.specs2".some).getOrElse(fail("Should not be called"))
+      )
+    )
+
+    val (grouped, notGrouped) =
+      GroupedUpdate.from(List(pullRequestGroupSpecs2), List(updateSingleTypelevelAlgebra))
+    assertEquals(grouped, List.empty)
+    assertEquals(notGrouped, List(updateSingleTypelevelAlgebra))
+  }
+
+  test(
+    "GroupedUpdate.from: Do not group multiple Update.Single if the groupId does not match the filter"
+  ) {
+    val pullRequestGroupTypeLevel: PullRequestGroup = PullRequestGroup(
+      "typelevel",
+      Some("update typelevel"),
+      NonEmptyList.one(
+        PullRequestUpdateFilter("org.typelevel".some).getOrElse(fail("Should not be called"))
+      )
+    )
+    val (grouped, notGrouped) =
+      GroupedUpdate.from(
+        List(pullRequestGroupTypeLevel),
+        List(updateSingleSpecs2Core, updateSingleSpecs2Scalacheck)
+      )
+    assertEquals(grouped, List.empty)
+    assertEquals(notGrouped, List(updateSingleSpecs2Core, updateSingleSpecs2Scalacheck))
+  }
+
+  test("GroupedUpdate.from: Group a Update.Single for a single GroupId filter") {
+    val pullRequestGroupTypeLevel: PullRequestGroup = PullRequestGroup(
+      "typelevel",
+      Some("update typelevel"),
+      NonEmptyList.one(
+        PullRequestUpdateFilter("org.typelevel".some).getOrElse(fail("Should not be called"))
+      )
+    )
+
+    val (grouped, notGrouped) =
+      GroupedUpdate.from(
+        List(pullRequestGroupTypeLevel),
+        List(updateSingleTypelevelAlgebra)
+      )
+
+    assertEquals(
+      grouped,
+      List(GroupedUpdate("typelevel", Some("update typelevel"), List(updateSingleTypelevelAlgebra)))
+    )
+    assertEquals(notGrouped, List.empty)
+  }
+
+  test("GroupedUpdate.from: Group multiple Update.Single for a single GroupId filter") {
+    val pullRequestGroupSpecs2: PullRequestGroup = PullRequestGroup(
+      "specs2",
+      Some("update specs2"),
+      NonEmptyList.one(
+        PullRequestUpdateFilter("org.specs2".some).getOrElse(fail("Should not be called"))
+      )
+    )
+
+    val (grouped, notGrouped) =
+      GroupedUpdate.from(
+        List(pullRequestGroupSpecs2),
+        List(updateSingleSpecs2Core, updateSingleSpecs2Scalacheck)
+      )
+
+    assertEquals(
+      grouped,
+      List(
+        GroupedUpdate(
+          "specs2",
+          Some("update specs2"),
+          List(updateSingleSpecs2Core, updateSingleSpecs2Scalacheck)
+        )
+      )
+    )
+    assertEquals(notGrouped, List.empty)
+  }
+
+  test("GroupedUpdate.from: Group multiple Updates for multiple GroupId filters") {
+    val pullRequestGroupSpecs2: PullRequestGroup = PullRequestGroup(
+      "specs2",
+      Some("update specs2"),
+      NonEmptyList.one(
+        PullRequestUpdateFilter("org.specs2".some).getOrElse(fail("Should not be called"))
+      )
+    )
+
+    val pullRequestGroupTypeLevel: PullRequestGroup = PullRequestGroup(
+      "typelevel",
+      Some("update typelevel"),
+      NonEmptyList.one(
+        PullRequestUpdateFilter("org.typelevel".some).getOrElse(fail("Should not be called"))
+      )
+    )
+
+    val updates = List(
+      updateSingleSpecs2Core,
+      updateSingleSpecs2Scalacheck,
+      updateSingleTypelevelAlgebra,
+      updateSingleCirceCore
+    )
+    val pullRequestGroups = List(pullRequestGroupSpecs2, pullRequestGroupTypeLevel)
+
+    val (grouped, notGrouped) = GroupedUpdate.from(pullRequestGroups, updates)
+
+    assertEquals(
+      grouped,
+      List(
+        GroupedUpdate(
+          "specs2",
+          Some("update specs2"),
+          List(updateSingleSpecs2Core, updateSingleSpecs2Scalacheck)
+        ),
+        GroupedUpdate("typelevel", Some("update typelevel"), List(updateSingleTypelevelAlgebra))
+      )
+    )
+    assertEquals(notGrouped, List(updateSingleCirceCore))
+  }
+
+  val pullRequestGroupOrgWildcard: PullRequestGroup = PullRequestGroup(
+    "org",
+    Some("update org"),
+    NonEmptyList.one(PullRequestUpdateFilter("org.*".some).getOrElse(fail("Should not be called")))
+  )
+
+  test("GroupedUpdate.from: Group multiple Updates for a GroupId filter with a wildcard") {
+    val updates = List(
+      updateSingleSpecs2Core,
+      updateSingleSpecs2Scalacheck,
+      updateSingleTypelevelAlgebra,
+      updateSingleCirceCore
+    )
+    val pullRequestGroups = List(pullRequestGroupOrgWildcard)
+
+    val (grouped, notGrouped) = GroupedUpdate.from(pullRequestGroups, updates)
+
+    assertEquals(
+      grouped,
+      List(
+        GroupedUpdate(
+          "org",
+          Some("update org"),
+          List(updateSingleSpecs2Core, updateSingleSpecs2Scalacheck, updateSingleTypelevelAlgebra)
+        )
+      )
+    )
+    assertEquals(notGrouped, List(updateSingleCirceCore))
+  }
+
+  test("GroupedUpdate.from: Group everything when using * for OrganisationId") {
+    val pullRequestGroupWildcardAll: PullRequestGroup = PullRequestGroup(
+      "all wildcard",
+      Some("update all wildcard"),
+      NonEmptyList.one(PullRequestUpdateFilter("*".some).getOrElse(fail("Should not be called")))
+    )
+
+    val (grouped, notGrouped) =
+      GroupedUpdate.from(
+        List(pullRequestGroupWildcardAll),
+        List(
+          updateSingleTypelevelAlgebra,
+          updateSingleSpecs2Core,
+          updateSingleSpecs2Scalacheck
+        )
+      )
+    assertEquals(
+      grouped,
+      List(
+        GroupedUpdate(
+          "all wildcard",
+          Some("update all wildcard"),
+          List(
+            updateSingleTypelevelAlgebra,
+            updateSingleSpecs2Core,
+            updateSingleSpecs2Scalacheck
+          )
+        )
+      )
+    )
+    assertEquals(notGrouped, List.empty)
+  }
+
+  test("GroupedUpdate.from: Group Update.Group for an ArtifactId filter without a group") {
+    val pullRequestArtifactSpecs2CoreWithoutGroup: PullRequestGroup = PullRequestGroup(
+      "specs2 core",
+      Some("update specs2 core"),
+      NonEmptyList.one(
+        PullRequestUpdateFilter(None, "specs2-core".some).getOrElse(fail("Should not be called"))
+      )
+    )
+    val (grouped, notGrouped) =
+      GroupedUpdate.from(
+        List(pullRequestArtifactSpecs2CoreWithoutGroup),
+        List(updateSingleSpecs2Scalacheck, updateSingleSpecs2Core)
+      )
+    assertEquals(
+      grouped,
+      List(
+        GroupedUpdate(
+          "specs2 core",
+          Some("update specs2 core"),
+          List(updateSingleSpecs2Core)
+        )
+      )
+    )
+    assertEquals(notGrouped, List(updateSingleSpecs2Scalacheck))
+  }
+
+  val pullRequestArtifactSpecs2CoreWithGroup: PullRequestGroup = PullRequestGroup(
+    "specs2 core",
+    Some("update specs2 core"),
+    NonEmptyList.one(
+      PullRequestUpdateFilter("org.specs2".some, "specs2-core".some).getOrElse(
+        fail("Should not be called")
+      )
+    )
+  )
+
+  test("GroupedUpdate.from: Group multiple Update.Single for an ArtifactId filter with a group") {
+    val (grouped, notGrouped) =
+      GroupedUpdate.from(
+        List(pullRequestArtifactSpecs2CoreWithGroup),
+        List(updateSingleSpecs2Scalacheck, updateSingleSpecs2Core)
+      )
+    assertEquals(
+      grouped,
+      List(
+        GroupedUpdate(
+          "specs2 core",
+          Some("update specs2 core"),
+          List(updateSingleSpecs2Core)
+        )
+      )
+    )
+    assertEquals(notGrouped, List(updateSingleSpecs2Scalacheck))
+  }
+
+  test(
+    "GroupedUpdate.from: Not group multiple Update.Single for an ArtifactId filter with an invalid group"
+  ) {
+    val pullRequestArtifactSpecs2CoreWithInvalidGroup: PullRequestGroup = PullRequestGroup(
+      "specs2 core",
+      Some("update specs2 core"),
+      NonEmptyList.one(
+        PullRequestUpdateFilter("org.invalid".some, "specs2-core".some).getOrElse(
+          fail("Should not be called")
+        )
+      )
+    )
+
+    val (grouped, notGrouped) =
+      GroupedUpdate.from(
+        List(pullRequestArtifactSpecs2CoreWithInvalidGroup),
+        List(updateSingleSpecs2Scalacheck, updateSingleSpecs2Core)
+      )
+
+    assertEquals(grouped, List.empty)
+    assertEquals(notGrouped, List(updateSingleSpecs2Scalacheck, updateSingleSpecs2Core))
+  }
+
+  test(
+    "GroupedUpdate.from: Group multiple Update.Single for an ArtifactId filter with a wildcard"
+  ) {
+    val pullRequestArtifactSpecs2WildcardWithoutGroup: PullRequestGroup = PullRequestGroup(
+      "specs2",
+      Some("update specs2"),
+      NonEmptyList.one(
+        PullRequestUpdateFilter(None, "specs2-*".some).getOrElse(fail("Should not be called"))
+      )
+    )
+
+    val (grouped, notGrouped) =
+      GroupedUpdate.from(
+        List(pullRequestArtifactSpecs2WildcardWithoutGroup),
+        List(updateSingleSpecs2Scalacheck, updateSingleSpecs2Core, updateSingleTypelevelAlgebra)
+      )
+
+    assertEquals(
+      grouped,
+      List(
+        GroupedUpdate(
+          "specs2",
+          Some("update specs2"),
+          List(updateSingleSpecs2Scalacheck, updateSingleSpecs2Core)
+        )
+      )
+    )
+    assertEquals(notGrouped, List(updateSingleTypelevelAlgebra))
+  }
+
+  test(
+    "GroupedUpdate.from: Group multiple Update.Single for an ArtifactId filter with a wildcard and a group"
+  ) {
+    val pullRequestArtifactSpecs2WildcardWithGroup: PullRequestGroup = PullRequestGroup(
+      "specs2",
+      Some("update specs2"),
+      NonEmptyList.one(
+        PullRequestUpdateFilter("org.specs2".some, "specs2-*".some).getOrElse(
+          fail("Should not be called")
+        )
+      )
+    )
+    val (grouped, notGrouped) =
+      GroupedUpdate.from(
+        List(pullRequestArtifactSpecs2WildcardWithGroup),
+        List(updateSingleSpecs2Scalacheck, updateSingleSpecs2Core, updateSingleTypelevelAlgebra)
+      )
+    assertEquals(
+      grouped,
+      List(
+        GroupedUpdate(
+          "specs2",
+          Some("update specs2"),
+          List(updateSingleSpecs2Scalacheck, updateSingleSpecs2Core)
+        )
+      )
+    )
+    assertEquals(notGrouped, List(updateSingleTypelevelAlgebra))
+  }
+
+  test(
+    "GroupedUpdate.from: Not group multiple Update.Single for an ArtifactId filter with a wildcard an invalid group"
+  ) {
+    val pullRequestArtifactSpecs2WildcardWithInvalidGroup: PullRequestGroup = PullRequestGroup(
+      "specs2",
+      Some("update specs2"),
+      NonEmptyList.one(
+        PullRequestUpdateFilter("org.invalid".some, "specs2-*".some).getOrElse(
+          fail("Should not be called")
+        )
+      )
+    )
+
+    val (grouped, notGrouped) =
+      GroupedUpdate.from(
+        List(pullRequestArtifactSpecs2WildcardWithInvalidGroup),
+        List(updateSingleSpecs2Scalacheck, updateSingleSpecs2Core, updateSingleTypelevelAlgebra)
+      )
+
+    assertEquals(grouped, List.empty)
+    assertEquals(
+      notGrouped,
+      List(updateSingleSpecs2Scalacheck, updateSingleSpecs2Core, updateSingleTypelevelAlgebra)
+    )
+  }
+
+  val majorUpdate: Update.Single = ("org.major".g % "major".a % "3.1.2" %> "4.0.0").single
+  val minorUpdate: Update.Single = ("org.minor".g % "minor".a % "3.1.2" %> "3.2.0").single
+  val patchUpdate: Update.Single = ("org.patch".g % "patch".a % "3.1.2" %> "3.1.3").single
+
+  test(
+    "GroupedUpdate.from: Group major updates"
+  ) {
+    val pullRequestArtifactSpecs2WildcardWithInvalidGroup: PullRequestGroup = PullRequestGroup(
+      "major",
+      Some("update major"),
+      NonEmptyList.one(
+        PullRequestUpdateFilter(version = SemVer.Change.Major.some)
+          .getOrElse(fail("Should not be called"))
+      )
+    )
+
+    val (grouped, notGrouped) =
+      GroupedUpdate.from(
+        List(pullRequestArtifactSpecs2WildcardWithInvalidGroup),
+        List(majorUpdate, minorUpdate, patchUpdate)
+      )
+
+    assertEquals(
+      grouped,
+      List(
+        GroupedUpdate("major", Some("update major"), List(majorUpdate))
+      )
+    )
+
+    assertEquals(notGrouped, List(minorUpdate, patchUpdate))
+  }
+
+  test(
+    "GroupedUpdate.from: Group minor updates"
+  ) {
+    val pullRequestArtifactSpecs2WildcardWithInvalidGroup: PullRequestGroup = PullRequestGroup(
+      "minor",
+      Some("update minor"),
+      NonEmptyList.one(
+        PullRequestUpdateFilter(version = SemVer.Change.Minor.some)
+          .getOrElse(fail("Should not be called"))
+      )
+    )
+
+    val (grouped, notGrouped) =
+      GroupedUpdate.from(
+        List(pullRequestArtifactSpecs2WildcardWithInvalidGroup),
+        List(majorUpdate, minorUpdate, patchUpdate)
+      )
+
+    assertEquals(
+      grouped,
+      List(
+        GroupedUpdate("minor", Some("update minor"), List(minorUpdate))
+      )
+    )
+
+    assertEquals(notGrouped, List(majorUpdate, patchUpdate))
+  }
+
+  test(
+    "GroupedUpdate.from: Group patch updates"
+  ) {
+    val pullRequestArtifactSpecs2WildcardWithInvalidGroup: PullRequestGroup = PullRequestGroup(
+      "patch",
+      Some("update patch"),
+      NonEmptyList.one(
+        PullRequestUpdateFilter(version = SemVer.Change.Patch.some)
+          .getOrElse(fail("Should not be called"))
+      )
+    )
+
+    val (grouped, notGrouped) =
+      GroupedUpdate.from(
+        List(pullRequestArtifactSpecs2WildcardWithInvalidGroup),
+        List(majorUpdate, minorUpdate, patchUpdate)
+      )
+
+    assertEquals(
+      grouped,
+      List(
+        GroupedUpdate("patch", Some("update patch"), List(patchUpdate))
+      )
+    )
+
+    assertEquals(notGrouped, List(majorUpdate, minorUpdate))
+  }
+
+  test(
+    "GroupedUpdate.from: Group minor & patch updates"
+  ) {
+    val pullRequestArtifactSpecs2WildcardWithInvalidGroup: PullRequestGroup = PullRequestGroup(
+      "patch & minor",
+      Some("update patch & minor"),
+      NonEmptyList.of(
+        PullRequestUpdateFilter(version = SemVer.Change.Patch.some)
+          .getOrElse(fail("Should not be called")),
+        PullRequestUpdateFilter(version = SemVer.Change.Minor.some)
+          .getOrElse(fail("Should not be called"))
+      )
+    )
+
+    val (grouped, notGrouped) =
+      GroupedUpdate.from(
+        List(pullRequestArtifactSpecs2WildcardWithInvalidGroup),
+        List(majorUpdate, minorUpdate, patchUpdate)
+      )
+
+    assertEquals(
+      grouped,
+      List(
+        GroupedUpdate("patch & minor", Some("update patch & minor"), List(minorUpdate, patchUpdate))
+      )
+    )
+
+    assertEquals(notGrouped, List(majorUpdate))
+  }
+
+  test(
+    "GroupedUpdate.from: Group major & minor updates with version numbers containing a date and commit hash"
+  ) {
+    val majorUpdate: Update.Single =
+      ("org.major".g % "major".a % "1.0.0-20220920-180024-dd318047" %> "2.0.0-20220922-180024-dd318047").single
+    val minorUpdate: Update.Single =
+      ("org.minor".g % "minor".a % "1.0.0-20220920-180024-dd318047" %> "1.1.0-20220922-180024-dd318047").single
+    val patchUpdate: Update.Single =
+      ("org.patch".g % "patch".a % "1.0.0-20220920-180024-dd318047" %> "1.0.1-20220922-180024-dd318047").single
+
+    val pullRequestArtifactSpecs2WildcardWithInvalidGroup: PullRequestGroup = PullRequestGroup(
+      "major & minor",
+      Some("update major & minor"),
+      NonEmptyList.of(
+        PullRequestUpdateFilter(version = SemVer.Change.Major.some)
+          .getOrElse(fail("Should not be called")),
+        PullRequestUpdateFilter(version = SemVer.Change.Minor.some)
+          .getOrElse(fail("Should not be called"))
+      )
+    )
+
+    val (grouped, notGrouped) =
+      GroupedUpdate.from(
+        List(pullRequestArtifactSpecs2WildcardWithInvalidGroup),
+        List(majorUpdate, minorUpdate, patchUpdate)
+      )
+
+    assertEquals(
+      grouped,
+      List(
+        GroupedUpdate("major & minor", Some("update major & minor"), List(majorUpdate, minorUpdate))
+      )
+    )
+
+    assertEquals(notGrouped, List(patchUpdate))
+  }
+
+  test(
+    "GroupedUpdate.from: Group patch updates with version numbers containing a date and commit hash"
+  ) {
+    val majorUpdate: Update.Single =
+      ("org.major".g % "major".a % "1.0.0-20220920-180024-dd318047" %> "2.0.0-20220922-180024-dd318047").single
+    val minorUpdate: Update.Single =
+      ("org.minor".g % "minor".a % "1.0.0-20220920-180024-dd318047" %> "1.1.0-20220922-180024-dd318047").single
+    val patchUpdate: Update.Single =
+      ("org.patch".g % "patch".a % "1.0.0-20220920-180024-dd318047" %> "1.0.1-20220920-180024-dd318047").single
+
+    val pullRequestArtifactSpecs2WildcardWithInvalidGroup: PullRequestGroup = PullRequestGroup(
+      "patch",
+      Some("update patch"),
+      NonEmptyList.of(
+        PullRequestUpdateFilter(version = SemVer.Change.Patch.some)
+          .getOrElse(fail("Should not be called"))
+      )
+    )
+
+    val (grouped, notGrouped) =
+      GroupedUpdate.from(
+        List(pullRequestArtifactSpecs2WildcardWithInvalidGroup),
+        List(majorUpdate, minorUpdate, patchUpdate)
+      )
+
+    assertEquals(
+      grouped,
+      List(
+        GroupedUpdate("patch", Some("update patch"), List(patchUpdate))
+      )
+    )
+
+    assertEquals(notGrouped, List(majorUpdate, minorUpdate))
+  }
+}
diff --git a/modules/core/src/test/scala/org/scalasteward/core/git/FileGitAlgTest.scala b/modules/core/src/test/scala/org/scalasteward/core/git/FileGitAlgTest.scala
index 41cfe4b2..833b7910 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/git/FileGitAlgTest.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/git/FileGitAlgTest.scala
@@ -205,7 +205,7 @@ object FileGitAlgTest {
       for {
         _ <- gitAlg.removeClone(repo)
         _ <- fileAlg.ensureExists(repo)
-        _ <- git("init", ".")(repo)
+        _ <- git("init", ".", "--initial-branch", "master")(repo)
         _ <- gitAlg.setAuthor(repo, config.gitCfg.gitAuthor)
         _ <- git("commit", "--allow-empty", "-m", "Initial commit")(repo)
       } yield ()
diff --git a/modules/core/src/test/scala/org/scalasteward/core/nurture/PullRequestRepositoryTest.scala b/modules/core/src/test/scala/org/scalasteward/core/nurture/PullRequestRepositoryTest.scala
index 0d504497..a9bd8731 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/nurture/PullRequestRepositoryTest.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/nurture/PullRequestRepositoryTest.scala
@@ -5,6 +5,7 @@ import cats.effect.unsafe.implicits.global
 import munit.FunSuite
 import org.http4s.syntax.literals._
 import org.scalasteward.core.TestSyntax._
+import org.scalasteward.core.data.GroupedUpdate
 import org.scalasteward.core.git.Sha1.HexString
 import org.scalasteward.core.git.{Branch, Sha1}
 import org.scalasteward.core.mock.MockConfig.config
@@ -137,4 +138,58 @@ class PullRequestRepositoryTest extends FunSuite {
       )
     )
   }
+
+  test("findLatestPullRequest ignores grouped updates") {
+    val repo = Repo("pr-repo-test", "repo5")
+    val update = portableScala
+    val grouped = GroupedUpdate("group", None, List(update))
+    val data = PullRequestData[Id](url, sha1, grouped, Open, number, branch)
+
+    val p = for {
+      _ <- pullRequestRepository.createOrUpdate(repo, data)
+      result <- pullRequestRepository.findLatestPullRequest(repo, update.crossDependency, "1.0.0".v)
+    } yield result
+
+    val (state, result) = p.runSA(MockState.empty).unsafeRunSync()
+
+    val store =
+      config.workspace / s"store/pull_requests/v2/github/${repo.toPath}/pull_requests.json"
+    assert(result.isEmpty)
+
+    checkTrace(
+      state,
+      Vector(
+        Cmd("read", store.toString),
+        Cmd("write", store.toString)
+      )
+    )
+  }
+
+  test("lastPullRequestCreatedAt returns timestamp for grouped updates") {
+    val repo = Repo("pr-repo-test", "repo7")
+    val update = catsCore
+    val grouped = GroupedUpdate("group", None, List(update))
+    val data = PullRequestData[Id](url, sha1, grouped, Open, number, branch)
+
+    val p = for {
+      emptyCreatedAt <- pullRequestRepository.lastPullRequestCreatedAt(repo)
+      _ <- pullRequestRepository.createOrUpdate(repo, data)
+      createdAt <- pullRequestRepository.lastPullRequestCreatedAt(repo)
+    } yield (emptyCreatedAt, createdAt)
+    val (state, (emptyCreatedAt, createdAt)) = p.runSA(MockState.empty).unsafeRunSync()
+
+    val store =
+      config.workspace / s"store/pull_requests/v2/github/${repo.toPath}/pull_requests.json"
+    assert(emptyCreatedAt.isEmpty)
+    assert(createdAt.isDefined)
+
+    checkTrace(
+      state,
+      Vector(
+        Cmd("read", store.toString),
+        Cmd("write", store.toString)
+      )
+    )
+  }
+
 }
diff --git a/modules/core/src/test/scala/org/scalasteward/core/repoconfig/RepoConfigAlgTest.scala b/modules/core/src/test/scala/org/scalasteward/core/repoconfig/RepoConfigAlgTest.scala
index 1b4f7b7b..fea9ed99 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/repoconfig/RepoConfigAlgTest.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/repoconfig/RepoConfigAlgTest.scala
@@ -5,7 +5,7 @@ import cats.syntax.all._
 import eu.timepit.refined.types.numeric.NonNegInt
 import munit.FunSuite
 import org.scalasteward.core.TestSyntax._
-import org.scalasteward.core.data.GroupId
+import org.scalasteward.core.data.{GroupId, GroupedUpdate, SemVer}
 import org.scalasteward.core.mock.MockContext.context.repoConfigAlg
 import org.scalasteward.core.mock.MockState.TraceEntry.Log
 import org.scalasteward.core.mock.{MockConfig, MockState}
@@ -13,6 +13,7 @@ import org.scalasteward.core.util.Nel
 import org.scalasteward.core.vcs.data.Repo
 
 import scala.concurrent.duration._
+import cats.data.NonEmptyList
 
 class RepoConfigAlgTest extends FunSuite {
   test("default config is not empty") {
@@ -41,6 +42,13 @@ class RepoConfigAlgTest extends FunSuite {
          |updates.limit = 4
          |updates.fileExtensions = [ ".txt" ]
          |pullRequests.frequency = "@weekly"
+         |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 = "all", "filter" = [{"group" = "*"}] }
+         |]
          |dependencyOverrides = [
          |  { pullRequests.frequency = "@daily",   dependency = { groupId = "eu.timepit" } },
          |  { pullRequests.frequency = "@monthly", dependency = { groupId = "eu.timepit", artifactId = "refined.1" } },
@@ -57,7 +65,52 @@ class RepoConfigAlgTest extends FunSuite {
       .unsafeRunSync()
 
     val expected = RepoConfig(
-      pullRequests = PullRequestsConfig(frequency = Some(PullRequestFrequency.Timespan(7.days))),
+      pullRequests = PullRequestsConfig(
+        frequency = Some(PullRequestFrequency.Timespan(7.days)),
+        grouping = List(
+          PullRequestGroup(
+            name = "patches",
+            title = "Patch updates".some,
+            filter = NonEmptyList.of(
+              PullRequestUpdateFilter(None, None, SemVer.Change.Patch.some)
+                .getOrElse(fail("Should not be called"))
+            )
+          ),
+          PullRequestGroup(
+            name = "minor_major",
+            title = "Minor/major updates".some,
+            filter = NonEmptyList.of(
+              PullRequestUpdateFilter(None, None, SemVer.Change.Minor.some)
+                .getOrElse(fail("Should not be called")),
+              PullRequestUpdateFilter(None, None, SemVer.Change.Major.some)
+                .getOrElse(fail("Should not be called"))
+            )
+          ),
+          PullRequestGroup(
+            name = "typelevel",
+            title = "Typelevel updates".some,
+            filter = NonEmptyList.of(
+              PullRequestUpdateFilter("org.typelevel".some).getOrElse(fail("Should not be called")),
+              PullRequestUpdateFilter("org.http4s".some).getOrElse(fail("Should not be called"))
+            )
+          ),
+          PullRequestGroup(
+            name = "my_libraries",
+            filter = NonEmptyList.of(
+              PullRequestUpdateFilter(None, "my-library".some)
+                .getOrElse(fail("Should not be called")),
+              PullRequestUpdateFilter("my-org".some, "my-other-library".some)
+                .getOrElse(fail("Should not be called"))
+            )
+          ),
+          PullRequestGroup(
+            name = "all",
+            filter = NonEmptyList.of(
+              PullRequestUpdateFilter("*".some).getOrElse(fail("Should not be called"))
+            )
+          )
+        )
+      ),
       updates = UpdatesConfig(
         allow = List(UpdatePattern("eu.timepit".g, None, None)),
         pin = List(
@@ -229,6 +282,24 @@ class RepoConfigAlgTest extends FunSuite {
     assertEquals(config, expected)
   }
 
+  test("configToIgnoreFurtherUpdates with grouped update") {
+    val update1 = ("a".g % "b".a % "1" %> "2").single
+    val update2 = ("c".g % "d".a % "1" %> "2").single
+    val update = GroupedUpdate("my-group", None, List(update1, update2))
+    val config = RepoConfigAlg
+      .parseRepoConfig(RepoConfigAlg.configToIgnoreFurtherUpdates(update))
+      .getOrElse(RepoConfig())
+    val expected = RepoConfig(updates =
+      UpdatesConfig(ignore =
+        List(
+          UpdatePattern(groupId = "a".g, artifactId = "b".some, None),
+          UpdatePattern(groupId = "c".g, artifactId = "d".some, None)
+        )
+      )
+    )
+    assertEquals(config, expected)
+  }
+
   test("config with postUpdateHook without group and artifact id") {
     val content =
       """|postUpdateHooks = [{
diff --git a/modules/core/src/test/scala/org/scalasteward/core/vcs/VCSExtraAlgTest.scala b/modules/core/src/test/scala/org/scalasteward/core/vcs/VCSExtraAlgTest.scala
index 8e59bc72..92059aef 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/vcs/VCSExtraAlgTest.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/vcs/VCSExtraAlgTest.scala
@@ -36,14 +36,22 @@ class VCSExtraAlgTest extends FunSuite {
 
     assertEquals(
       vcsExtraAlg
-        .getReleaseRelatedUrls(uri"https://github.com/foo/foo", updateFoo)
+        .getReleaseRelatedUrls(
+          repoUrl = uri"https://github.com/foo/foo",
+          currentVersion = updateFoo.currentVersion,
+          nextVersion = updateFoo.nextVersion
+        )
         .unsafeRunSync(),
       List.empty
     )
 
     assertEquals(
       vcsExtraAlg
-        .getReleaseRelatedUrls(uri"https://github.com/foo/bar", updateBar)
+        .getReleaseRelatedUrls(
+          repoUrl = uri"https://github.com/foo/bar",
+          currentVersion = updateBar.currentVersion,
+          nextVersion = updateBar.nextVersion
+        )
         .unsafeRunSync(),
       List(
         ReleaseRelatedUrl.VersionDiff(uri"https://github.com/foo/bar/compare/v0.1.0...v0.2.0")
@@ -52,7 +60,11 @@ class VCSExtraAlgTest extends FunSuite {
 
     assertEquals(
       vcsExtraAlg
-        .getReleaseRelatedUrls(uri"https://github.com/foo/buz", updateBuz)
+        .getReleaseRelatedUrls(
+          repoUrl = uri"https://github.com/foo/buz",
+          currentVersion = updateBuz.currentVersion,
+          nextVersion = updateBuz.nextVersion
+        )
         .unsafeRunSync(),
       List.empty
     )
@@ -70,14 +82,22 @@ class VCSExtraAlgTest extends FunSuite {
 
     assertEquals(
       githubOnPremVcsExtraAlg
-        .getReleaseRelatedUrls(uri"https://github.on-prem.com/foo/foo", updateFoo)
+        .getReleaseRelatedUrls(
+          repoUrl = uri"https://github.on-prem.com/foo/foo",
+          currentVersion = updateFoo.currentVersion,
+          nextVersion = updateFoo.nextVersion
+        )
         .unsafeRunSync(),
       List.empty
     )
 
     assertEquals(
       githubOnPremVcsExtraAlg
-        .getReleaseRelatedUrls(uri"https://github.on-prem.com/foo/bar", updateBar)
+        .getReleaseRelatedUrls(
+          repoUrl = uri"https://github.on-prem.com/foo/bar",
+          currentVersion = updateBar.currentVersion,
+          nextVersion = updateBar.nextVersion
+        )
         .unsafeRunSync(),
       List(
         ReleaseRelatedUrl.VersionDiff(
@@ -88,7 +108,11 @@ class VCSExtraAlgTest extends FunSuite {
 
     assertEquals(
       githubOnPremVcsExtraAlg
-        .getReleaseRelatedUrls(uri"https://github.on-prem.com/foo/buz", updateFoo)
+        .getReleaseRelatedUrls(
+          repoUrl = uri"https://github.on-prem.com/foo/buz",
+          currentVersion = updateFoo.currentVersion,
+          nextVersion = updateFoo.nextVersion
+        )
         .unsafeRunSync(),
       List.empty
     )
diff --git a/modules/core/src/test/scala/org/scalasteward/core/vcs/VCSPackageTest.scala b/modules/core/src/test/scala/org/scalasteward/core/vcs/VCSPackageTest.scala
index 242fa309..43763a20 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/vcs/VCSPackageTest.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/vcs/VCSPackageTest.scala
@@ -5,213 +5,259 @@ import org.http4s.syntax.literals._
 import org.scalasteward.core.TestSyntax._
 import org.scalasteward.core.git
 import org.scalasteward.core.vcs.VCSType.{GitHub, GitLab}
+import org.scalasteward.core.data.GroupedUpdate
 import org.scalasteward.core.vcs.data.Repo
 
 class VCSPackageTest extends FunSuite {
   private val repo = Repo("foo", "bar")
-  private val update = ("ch.qos.logback".g % "logback-classic".a % "1.2.0" %> "1.2.3").single
-  private val updateBranch = git.branchFor(update, None)
 
-  test("listingBranch") {
-    assertEquals(listingBranch(GitHub, repo, updateBranch), s"foo/bar:${updateBranch.name}")
-    assertEquals(listingBranch(GitLab, repo, updateBranch), updateBranch.name)
-  }
+  // Single updates
 
-  test("createBranch") {
-    assertEquals(createBranch(GitHub, repo, updateBranch), s"foo:${updateBranch.name}")
-    assertEquals(createBranch(GitLab, repo, updateBranch), updateBranch.name)
-  }
+  {
 
-  test("possibleCompareUrls") {
-    val onPremVCS = "https://github.onprem.io/"
-    val onPremVCSUri = uri"https://github.onprem.io/"
+    val update = ("ch.qos.logback".g % "logback-classic".a % "1.2.0" %> "1.2.3").single
+    val updateBranch = git.branchFor(update, None)
 
-    assertEquals(
-      possibleCompareUrls(
-        GitHub,
-        onPremVCSUri,
-        uri"https://github.com/foo/bar",
-        update
-      ).map(_.url.renderString),
-      List(
-        "https://github.com/foo/bar/compare/v1.2.0...v1.2.3",
-        "https://github.com/foo/bar/compare/1.2.0...1.2.3",
-        "https://github.com/foo/bar/compare/release-1.2.0...release-1.2.3"
+    test("listingBranch (single)") {
+      assertEquals(listingBranch(GitHub, repo, updateBranch), s"foo/bar:${updateBranch.name}")
+      assertEquals(listingBranch(GitLab, repo, updateBranch), updateBranch.name)
+    }
+
+    test("createBranch (single)") {
+      assertEquals(createBranch(GitHub, repo, updateBranch), s"foo:${updateBranch.name}")
+      assertEquals(createBranch(GitLab, repo, updateBranch), updateBranch.name)
+    }
+
+    test("possibleCompareUrls") {
+      val onPremVCS = "https://github.onprem.io/"
+      val onPremVCSUri = uri"https://github.onprem.io/"
+
+      assertEquals(
+        possibleCompareUrls(
+          GitHub,
+          onPremVCSUri,
+          uri"https://github.com/foo/bar",
+          update.currentVersion,
+          update.nextVersion
+        ).map(_.url.renderString),
+        List(
+          "https://github.com/foo/bar/compare/v1.2.0...v1.2.3",
+          "https://github.com/foo/bar/compare/1.2.0...1.2.3",
+          "https://github.com/foo/bar/compare/release-1.2.0...release-1.2.3"
+        )
+      )
+
+      // should canonicalize (drop last slash)
+      assertEquals(
+        possibleCompareUrls(
+          GitHub,
+          onPremVCSUri,
+          uri"https://github.com/foo/bar/",
+          update.currentVersion,
+          update.nextVersion
+        ).map(_.url.renderString),
+        List(
+          "https://github.com/foo/bar/compare/v1.2.0...v1.2.3",
+          "https://github.com/foo/bar/compare/1.2.0...1.2.3",
+          "https://github.com/foo/bar/compare/release-1.2.0...release-1.2.3"
+        )
+      )
+
+      assertEquals(
+        possibleCompareUrls(
+          GitHub,
+          onPremVCSUri,
+          uri"https://gitlab.com/foo/bar",
+          update.currentVersion,
+          update.nextVersion
+        ).map(_.url.renderString),
+        List(
+          "https://gitlab.com/foo/bar/compare/v1.2.0...v1.2.3",
+          "https://gitlab.com/foo/bar/compare/1.2.0...1.2.3",
+          "https://gitlab.com/foo/bar/compare/release-1.2.0...release-1.2.3"
+        )
+      )
+
+      assertEquals(
+        possibleCompareUrls(
+          GitHub,
+          onPremVCSUri,
+          uri"https://bitbucket.org/foo/bar",
+          update.currentVersion,
+          update.nextVersion
+        ).map(_.url.renderString),
+        List(
+          "https://bitbucket.org/foo/bar/compare/v1.2.3..v1.2.0#diff",
+          "https://bitbucket.org/foo/bar/compare/1.2.3..1.2.0#diff",
+          "https://bitbucket.org/foo/bar/compare/release-1.2.3..release-1.2.0#diff"
+        )
+      )
+
+      assertEquals(
+        possibleCompareUrls(
+          GitHub,
+          onPremVCSUri,
+          uri"https://scalacenter.github.io/scalafix/",
+          update.currentVersion,
+          update.nextVersion
+        ),
+        List.empty
       )
-    )
 
-    // should canonicalize (drop last slash)
-    assertEquals(
-      possibleCompareUrls(
+      assertEquals(
+        possibleCompareUrls(
+          GitHub,
+          onPremVCSUri,
+          onPremVCSUri.addPath("foo/bar"),
+          update.currentVersion,
+          update.nextVersion
+        ).map(_.url.renderString),
+        List(
+          s"${onPremVCS}foo/bar/compare/v1.2.0...v1.2.3",
+          s"${onPremVCS}foo/bar/compare/1.2.0...1.2.3",
+          s"${onPremVCS}foo/bar/compare/release-1.2.0...release-1.2.3"
+        )
+      )
+    }
+
+    test("possibleChangelogUrls: github.com") {
+      val obtained = possibleReleaseRelatedUrls(
         GitHub,
-        onPremVCSUri,
-        uri"https://github.com/foo/bar/",
-        update
-      ).map(_.url.renderString),
-      List(
+        uri"https://github.com",
+        uri"https://github.com/foo/bar",
+        update.currentVersion,
+        update.nextVersion
+      ).map(_.url.renderString)
+      val expected = List(
+        "https://github.com/foo/bar/releases/tag/v1.2.3",
+        "https://github.com/foo/bar/releases/tag/1.2.3",
+        "https://github.com/foo/bar/releases/tag/release-1.2.3",
+        "https://github.com/foo/bar/blob/master/ReleaseNotes.md",
+        "https://github.com/foo/bar/blob/master/ReleaseNotes.markdown",
+        "https://github.com/foo/bar/blob/master/ReleaseNotes.rst",
+        "https://github.com/foo/bar/blob/master/RELEASES.md",
+        "https://github.com/foo/bar/blob/master/RELEASES.markdown",
+        "https://github.com/foo/bar/blob/master/RELEASES.rst",
+        "https://github.com/foo/bar/blob/master/Releases.md",
+        "https://github.com/foo/bar/blob/master/Releases.markdown",
+        "https://github.com/foo/bar/blob/master/Releases.rst",
+        "https://github.com/foo/bar/blob/master/releases.md",
+        "https://github.com/foo/bar/blob/master/releases.markdown",
+        "https://github.com/foo/bar/blob/master/releases.rst",
+        "https://github.com/foo/bar/blob/master/CHANGELOG.md",
+        "https://github.com/foo/bar/blob/master/CHANGELOG.markdown",
+        "https://github.com/foo/bar/blob/master/CHANGELOG.rst",
+        "https://github.com/foo/bar/blob/master/Changelog.md",
+        "https://github.com/foo/bar/blob/master/Changelog.markdown",
+        "https://github.com/foo/bar/blob/master/Changelog.rst",
+        "https://github.com/foo/bar/blob/master/changelog.md",
+        "https://github.com/foo/bar/blob/master/changelog.markdown",
+        "https://github.com/foo/bar/blob/master/changelog.rst",
+        "https://github.com/foo/bar/blob/master/CHANGES.md",
+        "https://github.com/foo/bar/blob/master/CHANGES.markdown",
+        "https://github.com/foo/bar/blob/master/CHANGES.rst",
         "https://github.com/foo/bar/compare/v1.2.0...v1.2.3",
         "https://github.com/foo/bar/compare/1.2.0...1.2.3",
         "https://github.com/foo/bar/compare/release-1.2.0...release-1.2.3"
       )
-    )
+      assertEquals(obtained, expected)
+    }
 
-    assertEquals(
-      possibleCompareUrls(
+    test("possibleChangelogUrls: gitlab.com") {
+      val obtained = possibleReleaseRelatedUrls(
         GitHub,
-        onPremVCSUri,
+        uri"https://github.com",
         uri"https://gitlab.com/foo/bar",
-        update
-      ).map(_.url.renderString),
-      List(
-        "https://gitlab.com/foo/bar/compare/v1.2.0...v1.2.3",
-        "https://gitlab.com/foo/bar/compare/1.2.0...1.2.3",
-        "https://gitlab.com/foo/bar/compare/release-1.2.0...release-1.2.3"
+        update.currentVersion,
+        update.nextVersion
+      ).map(_.url.renderString)
+      val expected =
+        possibleReleaseNotesFilenames.map(name =>
+          s"https://gitlab.com/foo/bar/blob/master/$name"
+        ) ++
+          possibleChangelogFilenames.map(name => s"https://gitlab.com/foo/bar/blob/master/$name") ++
+          List(
+            "https://gitlab.com/foo/bar/compare/v1.2.0...v1.2.3",
+            "https://gitlab.com/foo/bar/compare/1.2.0...1.2.3",
+            "https://gitlab.com/foo/bar/compare/release-1.2.0...release-1.2.3"
+          )
+      assertEquals(obtained, expected)
+    }
+
+    test("possibleChangelogUrls: on-prem gitlab") {
+      val obtained = possibleReleaseRelatedUrls(
+        GitLab,
+        uri"https://gitlab.on-prem.net",
+        uri"https://gitlab.on-prem.net/foo/bar",
+        update.currentVersion,
+        update.nextVersion
+      ).map(_.url.renderString)
+      val expected = possibleReleaseNotesFilenames.map(name =>
+        s"https://gitlab.on-prem.net/foo/bar/blob/master/$name"
+      ) ++ possibleChangelogFilenames.map(name =>
+        s"https://gitlab.on-prem.net/foo/bar/blob/master/$name"
+      ) ++ List(
+        "https://gitlab.on-prem.net/foo/bar/compare/v1.2.0...v1.2.3",
+        "https://gitlab.on-prem.net/foo/bar/compare/1.2.0...1.2.3",
+        "https://gitlab.on-prem.net/foo/bar/compare/release-1.2.0...release-1.2.3"
       )
-    )
+      assertEquals(obtained, expected)
+    }
 
-    assertEquals(
-      possibleCompareUrls(
+    test("possibleChangelogUrls: bitbucket.org") {
+      val obtained = possibleReleaseRelatedUrls(
         GitHub,
-        onPremVCSUri,
+        uri"https://github.com",
         uri"https://bitbucket.org/foo/bar",
-        update
-      ).map(_.url.renderString),
-      List(
-        "https://bitbucket.org/foo/bar/compare/v1.2.3..v1.2.0#diff",
-        "https://bitbucket.org/foo/bar/compare/1.2.3..1.2.0#diff",
-        "https://bitbucket.org/foo/bar/compare/release-1.2.3..release-1.2.0#diff"
-      )
-    )
+        update.currentVersion,
+        update.nextVersion
+      ).map(_.url.renderString)
+      val expected =
+        possibleReleaseNotesFilenames.map(name => s"https://bitbucket.org/foo/bar/master/$name") ++
+          possibleChangelogFilenames.map(name => s"https://bitbucket.org/foo/bar/master/$name") ++
+          List(
+            "https://bitbucket.org/foo/bar/compare/v1.2.3..v1.2.0#diff",
+            "https://bitbucket.org/foo/bar/compare/1.2.3..1.2.0#diff",
+            "https://bitbucket.org/foo/bar/compare/release-1.2.3..release-1.2.0#diff"
+          )
+      assertEquals(obtained, expected)
+    }
 
-    assertEquals(
-      possibleCompareUrls(
+    test("possibleChangelogUrls: homepage") {
+      val obtained = possibleReleaseRelatedUrls(
         GitHub,
-        onPremVCSUri,
+        uri"https://github.com",
         uri"https://scalacenter.github.io/scalafix/",
-        update
-      ),
-      List.empty
-    )
+        update.currentVersion,
+        update.nextVersion
+      ).map(_.url.renderString)
+      assertEquals(obtained, List.empty)
+    }
 
-    assertEquals(
-      possibleCompareUrls(
-        GitHub,
-        onPremVCSUri,
-        onPremVCSUri.addPath("foo/bar"),
-        update
-      ).map(_.url.renderString),
-      List(
-        s"${onPremVCS}foo/bar/compare/v1.2.0...v1.2.3",
-        s"${onPremVCS}foo/bar/compare/1.2.0...1.2.3",
-        s"${onPremVCS}foo/bar/compare/release-1.2.0...release-1.2.3"
-      )
-    )
   }
 
-  test("possibleChangelogUrls: github.com") {
-    val obtained = possibleReleaseRelatedUrls(
-      GitHub,
-      uri"https://github.com",
-      uri"https://github.com/foo/bar",
-      update
-    ).map(_.url.renderString)
-    val expected = List(
-      "https://github.com/foo/bar/releases/tag/v1.2.3",
-      "https://github.com/foo/bar/releases/tag/1.2.3",
-      "https://github.com/foo/bar/releases/tag/release-1.2.3",
-      "https://github.com/foo/bar/blob/master/ReleaseNotes.md",
-      "https://github.com/foo/bar/blob/master/ReleaseNotes.markdown",
-      "https://github.com/foo/bar/blob/master/ReleaseNotes.rst",
-      "https://github.com/foo/bar/blob/master/RELEASES.md",
-      "https://github.com/foo/bar/blob/master/RELEASES.markdown",
-      "https://github.com/foo/bar/blob/master/RELEASES.rst",
-      "https://github.com/foo/bar/blob/master/Releases.md",
-      "https://github.com/foo/bar/blob/master/Releases.markdown",
-      "https://github.com/foo/bar/blob/master/Releases.rst",
-      "https://github.com/foo/bar/blob/master/releases.md",
-      "https://github.com/foo/bar/blob/master/releases.markdown",
-      "https://github.com/foo/bar/blob/master/releases.rst",
-      "https://github.com/foo/bar/blob/master/CHANGELOG.md",
-      "https://github.com/foo/bar/blob/master/CHANGELOG.markdown",
-      "https://github.com/foo/bar/blob/master/CHANGELOG.rst",
-      "https://github.com/foo/bar/blob/master/Changelog.md",
-      "https://github.com/foo/bar/blob/master/Changelog.markdown",
-      "https://github.com/foo/bar/blob/master/Changelog.rst",
-      "https://github.com/foo/bar/blob/master/changelog.md",
-      "https://github.com/foo/bar/blob/master/changelog.markdown",
-      "https://github.com/foo/bar/blob/master/changelog.rst",
-      "https://github.com/foo/bar/blob/master/CHANGES.md",
-      "https://github.com/foo/bar/blob/master/CHANGES.markdown",
-      "https://github.com/foo/bar/blob/master/CHANGES.rst",
-      "https://github.com/foo/bar/compare/v1.2.0...v1.2.3",
-      "https://github.com/foo/bar/compare/1.2.0...1.2.3",
-      "https://github.com/foo/bar/compare/release-1.2.0...release-1.2.3"
-    )
-    assertEquals(obtained, expected)
-  }
+  // Grouped updates
 
-  test("possibleChangelogUrls: gitlab.com") {
-    val obtained = possibleReleaseRelatedUrls(
-      GitHub,
-      uri"https://github.com",
-      uri"https://gitlab.com/foo/bar",
-      update
-    ).map(_.url.renderString)
-    val expected =
-      possibleReleaseNotesFilenames.map(name => s"https://gitlab.com/foo/bar/blob/master/$name") ++
-        possibleChangelogFilenames.map(name => s"https://gitlab.com/foo/bar/blob/master/$name") ++
-        List(
-          "https://gitlab.com/foo/bar/compare/v1.2.0...v1.2.3",
-          "https://gitlab.com/foo/bar/compare/1.2.0...1.2.3",
-          "https://gitlab.com/foo/bar/compare/release-1.2.0...release-1.2.3"
-        )
-    assertEquals(obtained, expected)
-  }
+  {
 
-  test("possibleChangelogUrls: on-prem gitlab") {
-    val obtained = possibleReleaseRelatedUrls(
-      GitLab,
-      uri"https://gitlab.on-prem.net",
-      uri"https://gitlab.on-prem.net/foo/bar",
-      update
-    ).map(_.url.renderString)
-    val expected = possibleReleaseNotesFilenames.map(name =>
-      s"https://gitlab.on-prem.net/foo/bar/blob/master/$name"
-    ) ++ possibleChangelogFilenames.map(name =>
-      s"https://gitlab.on-prem.net/foo/bar/blob/master/$name"
-    ) ++ List(
-      "https://gitlab.on-prem.net/foo/bar/compare/v1.2.0...v1.2.3",
-      "https://gitlab.on-prem.net/foo/bar/compare/1.2.0...1.2.3",
-      "https://gitlab.on-prem.net/foo/bar/compare/release-1.2.0...release-1.2.3"
+    val update = GroupedUpdate(
+      name = "my-group",
+      title = None,
+      updates = List(("ch.qos.logback".g % "logback-classic".a % "1.2.0" %> "1.2.3").single)
     )
-    assertEquals(obtained, expected)
-  }
 
-  test("possibleChangelogUrls: bitbucket.org") {
-    val obtained = possibleReleaseRelatedUrls(
-      GitHub,
-      uri"https://github.com",
-      uri"https://bitbucket.org/foo/bar",
-      update
-    ).map(_.url.renderString)
-    val expected =
-      possibleReleaseNotesFilenames.map(name => s"https://bitbucket.org/foo/bar/master/$name") ++
-        possibleChangelogFilenames.map(name => s"https://bitbucket.org/foo/bar/master/$name") ++
-        List(
-          "https://bitbucket.org/foo/bar/compare/v1.2.3..v1.2.0#diff",
-          "https://bitbucket.org/foo/bar/compare/1.2.3..1.2.0#diff",
-          "https://bitbucket.org/foo/bar/compare/release-1.2.3..release-1.2.0#diff"
-        )
-    assertEquals(obtained, expected)
-  }
+    val updateBranch = git.branchFor(update, None)
+
+    test("listingBranch (grouped)") {
+      assertEquals(listingBranch(GitHub, repo, updateBranch), s"foo/bar:update/my-group")
+      assertEquals(listingBranch(GitLab, repo, updateBranch), updateBranch.name)
+    }
+
+    test("createBranch (grouped)") {
+      assertEquals(createBranch(GitHub, repo, updateBranch), s"foo:update/my-group")
+      assertEquals(createBranch(GitLab, repo, updateBranch), updateBranch.name)
+    }
 
-  test("possibleChangelogUrls: homepage") {
-    val obtained = possibleReleaseRelatedUrls(
-      GitHub,
-      uri"https://github.com",
-      uri"https://scalacenter.github.io/scalafix/",
-      update
-    ).map(_.url.renderString)
-    assertEquals(obtained, List.empty)
   }
+
 }
diff --git a/modules/core/src/test/scala/org/scalasteward/core/vcs/data/NewPullRequestDataTest.scala b/modules/core/src/test/scala/org/scalasteward/core/vcs/data/NewPullRequestDataTest.scala
index 8783e266..e652f489 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/vcs/data/NewPullRequestDataTest.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/vcs/data/NewPullRequestDataTest.scala
@@ -1,12 +1,13 @@
 package org.scalasteward.core.vcs.data
 
+import cats.syntax.all._
 import io.circe.syntax._
 import munit.FunSuite
 import org.http4s.syntax.literals._
 import org.scalasteward.core.TestInstances._
 import org.scalasteward.core.TestSyntax._
 import org.scalasteward.core.buildtool.sbt.data.SbtVersion
-import org.scalasteward.core.data.{ReleaseRelatedUrl, RepoData, UpdateData, Version}
+import org.scalasteward.core.data.{GroupedUpdate, ReleaseRelatedUrl, RepoData, UpdateData, Version}
 import org.scalasteward.core.edit.EditAttempt.{ScalafixEdit, UpdateEdit}
 import org.scalasteward.core.edit.scalafix.ScalafixMigration
 import org.scalasteward.core.git.{Branch, Commit}
@@ -242,19 +243,19 @@ class NewPullRequestDataTest extends FunSuite {
   test("updateType") {
     val dependency = "com.example".g % "foo".a % "0.1"
     val single = (dependency %> "0.2").single
-    assertEquals(updateType(single), "library-update")
+    assertEquals(updateType(single), List("library-update"))
 
     val group = ("com.example".g % Nel.of("foo".a, "bar".a) % "0.1" %> "0.2").group
-    assertEquals(updateType(group), "library-update")
+    assertEquals(updateType(group), List("library-update"))
 
     val testUpdate = (dependency % "test" %> "0.2").single
-    assertEquals(updateType(testUpdate), "test-library-update")
+    assertEquals(updateType(testUpdate), List("test-library-update"))
 
     val sbtPluginUpdate = (dependency.copy(sbtVersion = Some(SbtVersion("1.0"))) %> "0.2").single
-    assertEquals(updateType(sbtPluginUpdate), "sbt-plugin-update")
+    assertEquals(updateType(sbtPluginUpdate), List("sbt-plugin-update"))
 
     val scalafixRuleUpdate = (dependency % "scalafix-rule" %> "0.2").single
-    assertEquals(updateType(scalafixRuleUpdate), "scalafix-rule-update")
+    assertEquals(updateType(scalafixRuleUpdate), List("scalafix-rule-update"))
   }
 
   test("oldVersionNote without files") {
@@ -318,4 +319,161 @@ class NewPullRequestDataTest extends FunSuite {
     val second = labelsFor(update, List(updateEdit), List.empty, Some("(.*update.*)|(.*count.*)".r))
     assertEquals(clue(second), List("library-update", "commit-count:1"))
   }
+
+  test("label for grouped updates add labels for all update types & version changes") {
+    val update1 = ("a".g % "b".a % "1" -> "2").single
+    val update2 = ("c".g % "d".a % "1.1.0" % "test" %> "1.2.0").single
+    val update = GroupedUpdate("my-group", None, List(update1, update2))
+
+    val labels = labelsFor(update, Nil, Nil, None)
+
+    val expected = List(
+      "library-update",
+      "test-library-update",
+      "early-semver-minor",
+      "semver-spec-minor",
+      "commit-count:0"
+    )
+
+    assertEquals(labels, expected)
+  }
+
+  test("oldVersionNote doesn't show version for grouped updates") {
+    val files = List("Readme.md", "travis.yml")
+    val update1 = ("a".g % "b".a % "1" -> "2").single
+    val update2 = ("c".g % "d".a % "1.1.0" % "test" %> "1.2.0").single
+    val update = GroupedUpdate("my-group", None, List(update1, update2))
+
+    val note = oldVersionNote(files, update)
+
+    assertEquals(
+      note.fold("")(_.toHtml),
+      """<details>
+        |<summary>Files still referring to the old version numbers</summary>
+        |
+        |The following files still refer to the old version numbers.
+        |You might want to review and update them manually.
+        |```
+        |Readme.md
+        |travis.yml
+        |```
+        |</details>
+      """.stripMargin.trim
+    )
+  }
+
+  test("adjustFutureUpdates for grouped udpates shows settings for each update") {
+    val update1 = ("a".g % "b".a % "1" -> "2").single
+    val update2 = ("c".g % "d".a % "1.1.0" % "test" %> "1.2.0").single
+    val update = GroupedUpdate("my-group", None, List(update1, update2))
+
+    val note = adjustFutureUpdates(update)
+
+    assertEquals(
+      note.toHtml,
+      """<details>
+        |<summary>Adjust future updates</summary>
+        |
+        |Add these to your `.scala-steward.conf` file to ignore future updates of these dependencies:
+        |```
+        |updates.ignore = [
+        |  { groupId = "a", artifactId = "b" },
+        |  { groupId = "c", artifactId = "d" }
+        |]
+        |```
+        |Or, add these to slow down future updates of these dependencies:
+        |```
+        |dependencyOverrides = [
+        |  {
+        |    pullRequests = { frequency = "@monthly" },
+        |    dependency = { groupId = "a", artifactId = "b" }
+        |  },
+        |  {
+        |    pullRequests = { frequency = "@monthly" },
+        |    dependency = { groupId = "c", artifactId = "d" }
+        |  }
+        |]
+        |```
+        |</details>
+      """.stripMargin.trim
+    )
+  }
+
+  test("NewPullRequestData.from works for `GroupedUpdate`") {
+    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 = GroupedUpdate("my-group", "The PR title".some, List(update1, update2))
+    val data = UpdateData(
+      RepoData(Repo("foo", "bar"), dummyRepoCache, RepoConfig.empty),
+      Repo("scala-steward", "bar"),
+      update,
+      Branch("master"),
+      dummySha1,
+      Branch("update/logback-classic-1.2.3")
+    )
+
+    val obtained = from(data, "scala-steward:update/logback-classic-1.2.3").asJson.spaces2
+
+    val body =
+      raw"""Updates:
+           |
+           |* ch.qos.logback:logback-classic from 1.2.0 to 1.2.3
+           |* com.example:foo from 1.0.0 to 2.0.0
+           |
+           |
+           |I'll automatically update this PR to resolve conflicts as long as you don't change it yourself.
+           |
+           |If you have any feedback, just mention me in the comments below.
+           |
+           |Configure Scala Steward for your repository with a [`.scala-steward.conf`](https://github.com/scala-steward-org/scala-steward/blob/${org.scalasteward.core.BuildInfo.gitHeadCommit}/docs/repo-specific-configuration.md) file.
+           |
+           |Have a fantastic day writing Scala!
+           |
+           |<details>
+           |<summary>Adjust future updates</summary>
+           |
+           |Add these to your `.scala-steward.conf` file to ignore future updates of these dependencies:
+           |```
+           |updates.ignore = [
+           |  { groupId = \"ch.qos.logback\", artifactId = \"logback-classic\" },
+           |  { groupId = \"com.example\", artifactId = \"foo\" }
+           |]
+           |```
+           |Or, add these to slow down future updates of these dependencies:
+           |```
+           |dependencyOverrides = [
+           |  {
+           |    pullRequests = { frequency = \"@monthly\" },
+           |    dependency = { groupId = \"ch.qos.logback\", artifactId = \"logback-classic\" }
+           |  },
+           |  {
+           |    pullRequests = { frequency = \"@monthly\" },
+           |    dependency = { groupId = \"com.example\", artifactId = \"foo\" }
+           |  }
+           |]
+           |```
+           |</details>
+           |
+           |labels: library-update, early-semver-patch, semver-spec-patch, early-semver-major, semver-spec-major, commit-count:0""".stripMargin
+
+    val expected =
+      raw"""|{
+            |  "title" : "The PR title",
+            |  "body" : "${body.replace("\n", "\\n")}",
+            |  "head" : "scala-steward:update/logback-classic-1.2.3",
+            |  "base" : "master",
+            |  "labels" : [
+            |    "library-update",
+            |    "early-semver-patch",
+            |    "semver-spec-patch",
+            |    "early-semver-major",
+            |    "semver-spec-major",
+            |    "commit-count:0"
+            |  ],
+            |  "draft" : false
+            |}""".stripMargin
+
+    assertNoDiff(obtained, expected)
+  }
+
 }