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

Issue:
Time-based delay configuration for new dependencies
I found myself looking for a feature where you could say "Only update a dependency if a configured amount of time has passed" but I couldn't find an already existing solution to this. I imagine a feature like this could be set per groupId/dependency, but also globally.

The biggest motivation for me being that receiving daily PRs for dependencies that get released nightly (like any of the AWS SDK modules) can be a bit much, especially for expensive, long-running CI builds. If I could specify a "delay period" then I could effectively change these daily updates to be weekly and so on.

Also, sometimes brand new releases get unintentional breaking changes shipped out (which are usually later corrected within a couple of days). This feature could be useful for filtering out things like that. Like "I only want to trust a new dependency if X amount of time has passed without a new release".

Pull request:
Closes #1581
May supersedes #2322

Patch:
diff --git a/docs/repo-specific-configuration.md b/docs/repo-specific-configuration.md
index cc60ff33f1..54b8c0f049 100644
--- a/docs/repo-specific-configuration.md
+++ b/docs/repo-specific-configuration.md
@@ -93,6 +93,28 @@ postUpdateHooks = [{
   groupId = "com.github.sbt",
   artifiactId = "sbt-protobuf"
 }]
+
+# You can override some config options for dependencies that matches the given pattern.
+# Currently, "pullRequests" can be overridden.  
+# Each pattern must have `groupId`, and may have `artifactId` and `version`.
+# First-matched entry is used.
+# More-specific entry should be placed before less-specific entry.
+#
+# Default: empty `[]`
+dependencyOverrides = [
+  {
+    dependency = { groupId = "com.example", artifactId = "foo", version = "2." },
+    pullRequests = { frequency = "1 day" },
+  },
+  {
+    dependency = { groupId = "com.example", artifactId = "foo" },
+    pullRequests = { frequency = "30 day" },
+  },
+  {
+    dependency = { groupId = "com.example" },
+    pullRequests = { frequency = "14 day" },
+  }
+]
 ```
 
 The version information given in the patterns above can be in two formats:
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 754acfc55a..b82a744abd 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, Update, Version}
+import org.scalasteward.core.data.{CrossDependency, GroupId, Update, Version}
 import org.scalasteward.core.git
 import org.scalasteward.core.git.{Branch, Sha1}
 import org.scalasteward.core.nurture.PullRequestRepository.Entry
@@ -112,6 +112,17 @@ final class PullRequestRepository[F[_]](kvStore: KeyValueStore[F, Repo, Map[Uri,
 
   def lastPullRequestCreatedAt(repo: Repo): F[Option[Timestamp]] =
     kvStore.get(repo).map(_.flatMap(_.values.map(_.entryCreatedAt).maxOption))
+
+  def lastPullRequestCreatedAtByArtifact(repo: Repo): F[Map[(GroupId, String), Timestamp]] =
+    kvStore.get(repo).map {
+      case None => Map.empty
+      case Some(pullRequests) =>
+        pullRequests.values
+          .groupBy(entry => (entry.update.groupId, entry.update.mainArtifactId))
+          .view
+          .mapValues(_.map(_.entryCreatedAt).max)
+          .toMap
+    }
 }
 
 object PullRequestRepository {
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
new file mode 100644
index 0000000000..122397088c
--- /dev/null
+++ b/modules/core/src/main/scala/org/scalasteward/core/repoconfig/GroupRepoConfig.scala
@@ -0,0 +1,35 @@
+/*
+ * 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 io.circe.Codec
+import io.circe.generic.extras.Configuration
+import io.circe.generic.semiauto.deriveCodec
+
+final case class GroupRepoConfig(
+    pullRequests: PullRequestsConfig = PullRequestsConfig(),
+    dependency: UpdatePattern
+)
+
+object GroupRepoConfig {
+  implicit val groupPullConfigCodecConfig: Configuration =
+    Configuration.default.withDefaults
+
+  implicit val groupPullConfigCodec: Codec[GroupRepoConfig] =
+    deriveCodec
+
+}
diff --git a/modules/core/src/main/scala/org/scalasteward/core/repoconfig/RepoConfig.scala b/modules/core/src/main/scala/org/scalasteward/core/repoconfig/RepoConfig.scala
index 22350b3503..866fb5cbd3 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/repoconfig/RepoConfig.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/repoconfig/RepoConfig.scala
@@ -31,7 +31,8 @@ final case class RepoConfig(
     updates: UpdatesConfig = UpdatesConfig(),
     postUpdateHooks: Option[List[PostUpdateHookConfig]] = None,
     updatePullRequests: Option[PullRequestUpdateStrategy] = None,
-    buildRoots: Option[List[BuildRootConfig]] = None
+    buildRoots: Option[List[BuildRootConfig]] = None,
+    dependencyOverrides: List[GroupRepoConfig] = List.empty
 ) {
   def buildRootsOrDefault: List[BuildRootConfig] =
     buildRoots
@@ -75,7 +76,8 @@ object RepoConfig {
               updates = x.updates |+| y.updates,
               postUpdateHooks = x.postUpdateHooks |+| y.postUpdateHooks,
               updatePullRequests = x.updatePullRequests.orElse(y.updatePullRequests),
-              buildRoots = x.buildRoots |+| y.buildRoots
+              buildRoots = x.buildRoots |+| y.buildRoots,
+              dependencyOverrides = x.dependencyOverrides |+| y.dependencyOverrides
             )
         }
     )
diff --git a/modules/core/src/main/scala/org/scalasteward/core/update/PruningAlg.scala b/modules/core/src/main/scala/org/scalasteward/core/update/PruningAlg.scala
index f8b0f658f9..8985eb974d 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/update/PruningAlg.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/update/PruningAlg.scala
@@ -17,17 +17,16 @@
 package org.scalasteward.core.update
 
 import cats.Monad
-import cats.data.OptionT
 import cats.implicits._
 import org.scalasteward.core.data._
 import org.scalasteward.core.nurture.PullRequestRepository
 import org.scalasteward.core.repocache.RepoCache
-import org.scalasteward.core.repoconfig.{PullRequestFrequency, RepoConfig}
+import org.scalasteward.core.repoconfig.{PullRequestFrequency, RepoConfig, UpdatePattern}
 import org.scalasteward.core.update.PruningAlg._
 import org.scalasteward.core.update.data.UpdateState
 import org.scalasteward.core.update.data.UpdateState._
 import org.scalasteward.core.util
-import org.scalasteward.core.util.{dateTime, DateTimeAlg, Nel}
+import org.scalasteward.core.util.{dateTime, DateTimeAlg, Nel, Timestamp}
 import org.scalasteward.core.vcs.data.Repo
 import org.typelevel.log4cats.Logger
 import scala.concurrent.duration._
@@ -129,37 +128,70 @@ final class PruningAlg[F[_]](implicit
       repoConfig: RepoConfig,
       updateStates: List[UpdateState]
   ): F[Option[Nel[WithUpdate]]] =
-    newPullRequestsAllowed(repo, repoConfig.pullRequests.frequencyOrDefault).flatMap { allowed =>
-      Nel.fromList(updateStates.collect {
-        case s: DependencyOutdated if allowed => s
-        case s: PullRequestOutdated           => s
-      }) match {
+    for {
+      now <- dateTimeAlg.currentTimestamp
+      repoLastPrCreatedAt <- pullRequestRepository.lastPullRequestCreatedAt(repo)
+      lastPullRequestCreatedAtByArtifact <- pullRequestRepository
+        .lastPullRequestCreatedAtByArtifact(repo)
+      states <- updateStates.traverseFilter[F, WithUpdate] {
+        case s: DependencyOutdated =>
+          newPullRequestsAllowed(
+            s,
+            now,
+            repoLastPrCreatedAt,
+            artifactLastPrCreatedAt =
+              lastPullRequestCreatedAtByArtifact.get(s.update.groupId -> s.update.mainArtifactId),
+            repoConfig
+          ).map {
+            case true  => Some(s)
+            case false => None
+          }
+        case s: PullRequestOutdated => Option[WithUpdate](s).pure[F]
+        case _                      => F.pure(None)
+      }
+      result <- Nel.fromList(states) match {
         case some @ Some(states) =>
           val lines = util.string.indentLines(states.map(UpdateState.show).sorted)
           logger.info(s"${repo.show} is outdated:\n" + lines).as(some)
         case None =>
           logger.info(s"${repo.show} is up-to-date").as(None)
       }
-    }
+    } yield result
 
-  private def newPullRequestsAllowed(repo: Repo, frequency: PullRequestFrequency): F[Boolean] =
-    if (frequency === PullRequestFrequency.Asap) true.pure[F]
-    else
-      dateTimeAlg.currentTimestamp.flatMap { now =>
-        val ignoring = "Ignoring outdated dependencies"
-        if (!frequency.onSchedule(now))
-          logger.info(s"$ignoring according to $frequency").as(false)
-        else {
-          val maybeWaitingTime = OptionT(pullRequestRepository.lastPullRequestCreatedAt(repo))
-            .subflatMap(frequency.waitingTime(_, now))
-          maybeWaitingTime.value.flatMap {
-            case None => true.pure[F]
-            case Some(waitingTime) =>
-              val message = s"$ignoring for ${dateTime.showDuration(waitingTime)}"
-              logger.info(message).as(false)
+  private def newPullRequestsAllowed(
+      dependencyOutdated: DependencyOutdated,
+      now: Timestamp,
+      repoLastPrCreatedAt: Option[Timestamp],
+      artifactLastPrCreatedAt: Option[Timestamp],
+      repoConfig: RepoConfig
+  ): F[Boolean] = {
+    val (frequencyz: Option[PullRequestFrequency], lastPrCreatedAt: Option[Timestamp]) =
+      repoConfig.dependencyOverrides
+        .collectFirstSome { groupRepoConfig =>
+          val matchResult = UpdatePattern
+            .findMatch(List(groupRepoConfig.dependency), dependencyOutdated.update, include = true)
+          if (matchResult.byArtifactId.nonEmpty && matchResult.filteredVersions.nonEmpty) {
+            Some((groupRepoConfig.pullRequests.frequency, artifactLastPrCreatedAt))
+          } else {
+            None
           }
         }
+        .getOrElse((repoConfig.pullRequests.frequency, repoLastPrCreatedAt))
+    val frequency = frequencyz.getOrElse(PullRequestFrequency.Asap)
+
+    val dep = dependencyOutdated.crossDependency.head
+    val ignoring = s"Ignoring outdated dependency ${dep.groupId}:${dep.artifactId.name}"
+    if (!frequency.onSchedule(now))
+      logger.info(s"$ignoring according to $frequency").as(false)
+    else {
+      lastPrCreatedAt.flatMap(frequency.waitingTime(_, now)) match {
+        case None => true.pure[F]
+        case Some(waitingTime) =>
+          val message = s"$ignoring for ${dateTime.showDuration(waitingTime)}"
+          logger.info(message).as(false)
       }
+    }
+  }
 }
 
 object PruningAlg {


Test patch:
diff --git a/modules/core/src/test/scala/org/scalasteward/core/TestInstances.scala b/modules/core/src/test/scala/org/scalasteward/core/TestInstances.scala
index 4669af2f84..cb8aea5295 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/TestInstances.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/TestInstances.scala
@@ -108,6 +108,12 @@ object TestInstances {
   implicit val pullRequestFrequencyArbitrary: Arbitrary[PullRequestFrequency] =
     Arbitrary(Arbitrary.arbitrary[FiniteDuration].flatMap(fd => Gen.oneOf(Asap, Timespan(fd))))
 
+  implicit val groupRepoConfigArbitrary: Arbitrary[GroupRepoConfig] =
+    Arbitrary(for {
+      pullRequestsConfig <- Arbitrary.arbitrary[PullRequestsConfig]
+      pattern <- Arbitrary.arbitrary[UpdatePattern]
+    } yield GroupRepoConfig(pullRequestsConfig, pattern))
+
   implicit val pullRequestsConfigArbitrary: Arbitrary[PullRequestsConfig] =
     Arbitrary(for {
       frequency <- Arbitrary.arbitrary[Option[PullRequestFrequency]]
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 e9bad5d794..2d5ba9ebb7 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,6 +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.mock.MockContext.context.repoConfigAlg
 import org.scalasteward.core.mock.MockState.TraceEntry.Log
 import org.scalasteward.core.mock.{MockConfig, MockState}
@@ -39,6 +40,11 @@ class RepoConfigAlgTest extends FunSuite {
          |updates.limit = 4
          |updates.fileExtensions = [ ".txt" ]
          |pullRequests.frequency = "@weekly"
+         |dependencyOverrides = [
+         |  { pullRequests.frequency = "@daily",   dependency = { groupId = "eu.timepit" } },
+         |  { pullRequests.frequency = "@monthly", dependency = { groupId = "eu.timepit", artifactId = "refined.1" } },
+         |  { pullRequests.frequency = "@weekly",  dependency = { groupId = "eu.timepit", artifactId = "refined.1", version = { prefix="1." } } },
+         |]
          |commits.message = "Update ${artifactName} from ${currentVersion} to ${nextVersion}"
          |buildRoots = [ ".", "subfolder/subfolder" ]
          |""".stripMargin
@@ -74,7 +80,31 @@ class RepoConfigAlgTest extends FunSuite {
       commits = CommitsConfig(
         message = Some("Update ${artifactName} from ${currentVersion} to ${nextVersion}")
       ),
-      buildRoots = Some(List(BuildRootConfig.repoRoot, BuildRootConfig("subfolder/subfolder")))
+      buildRoots = Some(List(BuildRootConfig.repoRoot, BuildRootConfig("subfolder/subfolder"))),
+      dependencyOverrides = List(
+        GroupRepoConfig(
+          dependency = UpdatePattern(GroupId("eu.timepit"), None, None),
+          pullRequests = PullRequestsConfig(
+            frequency = Some(PullRequestFrequency.Timespan(1.day))
+          )
+        ),
+        GroupRepoConfig(
+          dependency = UpdatePattern(GroupId("eu.timepit"), Some("refined.1"), None),
+          pullRequests = PullRequestsConfig(
+            frequency = Some(PullRequestFrequency.Timespan(30.days))
+          )
+        ),
+        GroupRepoConfig(
+          dependency = UpdatePattern(
+            GroupId("eu.timepit"),
+            Some("refined.1"),
+            Some(VersionPattern(prefix = Some("1.")))
+          ),
+          pullRequests = PullRequestsConfig(
+            frequency = Some(PullRequestFrequency.Timespan(7.days))
+          )
+        )
+      )
     )
     assertEquals(config, Right(expected))
   }
diff --git a/modules/core/src/test/scala/org/scalasteward/core/update/PruningAlgTest.scala b/modules/core/src/test/scala/org/scalasteward/core/update/PruningAlgTest.scala
index 0fe6989c93..9d437e6e37 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/update/PruningAlgTest.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/update/PruningAlgTest.scala
@@ -15,6 +15,8 @@ import org.scalasteward.core.repocache.RepoCache
 import org.scalasteward.core.repoconfig.RepoConfig
 import org.scalasteward.core.vcs.data.Repo
 
+import java.time.Instant
+
 class PruningAlgTest extends FunSuite {
   test("needsAttention") {
     val repo = Repo("fthomas", "scalafix-test")
@@ -211,4 +213,340 @@ class PruningAlgTest extends FunSuite {
       updateStates <- pruningAlg.needsAttention(data).runA(initial)
     } yield assertEquals(updateStates, None)).unsafeRunSync()
   }
+
+  test("needsAttention: group-specific frequency (monthly) vs repo frequency (asap)") {
+    val repo = Repo("pruning-test", "repo3")
+    val Right(repoCache) = decode[RepoCache](
+      s"""|{
+          |  "sha1": "12def27a837ba6dc9e17406cbbe342fba3527c14",
+          |  "dependencyInfos" : [
+          |    {
+          |      "value" : [
+          |        {
+          |          "dependency" : {
+          |            "groupId" : "software.awssdk",
+          |            "artifactId" : {
+          |              "name" : "s3",
+          |              "maybeCrossName" : null
+          |            },
+          |            "version" : "2.100.0",
+          |            "sbtVersion" : null,
+          |            "scalaVersion" : null,
+          |            "configurations" : null
+          |          },
+          |          "filesContainingVersion": [
+          |            "build.sbt"
+          |          ]
+          |        }
+          |      ],
+          |      "resolvers" : [
+          |        {
+          |          "MavenRepository" : {
+          |            "name" : "public",
+          |            "location" : "https://foobar.org/maven2/"
+          |          }
+          |        }
+          |      ]
+          |    }
+          |  ],
+          |  "maybeRepoConfig": {
+          |    "dependencyOverrides": [
+          |      {
+          |        "dependency": { "groupId": "software.awssdk" },
+          |        "pullRequests": { "frequency": "@monthly" }
+          |      }
+          |    ],
+          |    "pullRequests": {
+          |      "frequency": "@asap"
+          |    }
+          |  }
+          |}""".stripMargin
+    )
+    val pullRequestsFile =
+      config.workspace / s"store/pull_requests/v2/github/${repo.toPath}/pull_requests.json"
+    val timestampNow = Instant.now().toEpochMilli
+    val pullRequestsContent =
+      s"""|{
+          |  "https://github.com/${repo.toPath}/pull/27" : {
+          |    "baseSha1" : "12def27a837ba6dc9e17406cbbe342fba3527c14",
+          |    "update" : {
+          |      "Single" : {
+          |        "crossDependency" : [
+          |          {
+          |            "groupId" : "software.awssdk",
+          |            "artifactId" : {
+          |              "name" : "s3",
+          |              "maybeCrossName" : null
+          |            },
+          |            "version" : "2.100.0",
+          |            "sbtVersion" : null,
+          |            "scalaVersion" : null,
+          |            "configurations" : null
+          |          }
+          |        ],
+          |        "newerVersions" : [
+          |          "2.200.0"
+          |        ],
+          |        "newerGroupId" : null
+          |      }
+          |    },
+          |    "state" : "open",
+          |    "entryCreatedAt" : $timestampNow
+          |  }
+          |}""".stripMargin
+    val versionsFile =
+      config.workspace / "store/versions/v2/https/foobar.org/maven2/software/awssdk/s3/versions.json"
+    val versionsContent =
+      s"""|{
+          |  "updatedAt" : 9999999999999,
+          |  "versions" : [
+          |    "2.100.0",
+          |    "2.200.0",
+          |    "2.300.0"
+          |  ]
+          |}
+          |""".stripMargin
+    val initial = MockState.empty
+      .addFiles(pullRequestsFile -> pullRequestsContent, versionsFile -> versionsContent)
+      .unsafeRunSync()
+    val data = RepoData(repo, repoCache, repoCache.maybeRepoConfig.getOrElse(RepoConfig.empty))
+    val state = pruningAlg.needsAttention(data).runS(initial).unsafeRunSync()
+    val expected = initial.copy(
+      trace = Vector(
+        Log(s"Find updates for ${repo.show}"),
+        Cmd("read", versionsFile.toString),
+        Cmd("read", pullRequestsFile.toString),
+        Cmd("read", versionsFile.toString),
+        Log("Found 1 update:\n  software.awssdk:s3 : 2.100.0 -> 2.300.0"),
+        Log("Ignoring outdated dependency software.awssdk:s3 for 29d 23h 59m"),
+        Log(s"${repo.show} is up-to-date")
+      )
+    )
+    assertEquals(state, expected)
+  }
+
+  test("needsAttention: group-specific frequency (asap) vs repo frequency (monthly)") {
+    val repo = Repo("pruning-test", "repo3")
+    val Right(repoCache) = decode[RepoCache](
+      s"""|{
+          |  "sha1": "12def27a837ba6dc9e17406cbbe342fba3527c14",
+          |  "dependencyInfos" : [
+          |    {
+          |      "value" : [
+          |        {
+          |          "dependency" : {
+          |            "groupId" : "software.awssdk",
+          |            "artifactId" : {
+          |              "name" : "s3",
+          |              "maybeCrossName" : null
+          |            },
+          |            "version" : "2.100.0",
+          |            "sbtVersion" : null,
+          |            "scalaVersion" : null,
+          |            "configurations" : null
+          |          },
+          |          "filesContainingVersion": [
+          |            "build.sbt"
+          |          ]
+          |        }
+          |      ],
+          |      "resolvers" : [
+          |        {
+          |          "MavenRepository" : {
+          |            "name" : "public",
+          |            "location" : "https://foobar.org/maven2/"
+          |          }
+          |        }
+          |      ]
+          |    }
+          |  ],
+          |  "maybeRepoConfig": {
+          |    "dependencyOverrides": [
+          |      {
+          |        "dependency": { "groupId": "software.awssdk" },
+          |        "pullRequests": { "frequency": "@asap" }
+          |      }
+          |    ],
+          |    "pullRequests": {
+          |      "frequency": "@monthly"
+          |    }
+          |  }
+          |}""".stripMargin
+    )
+    val pullRequestsFile =
+      config.workspace / s"store/pull_requests/v2/github/${repo.toPath}/pull_requests.json"
+    val pullRequestsContent =
+      s"""|{
+          |  "https://github.com/${repo.toPath}/pull/27" : {
+          |    "baseSha1" : "12def27a837ba6dc9e17406cbbe342fba3527c14",
+          |    "update" : {
+          |      "Single" : {
+          |        "crossDependency" : [
+          |          {
+          |            "groupId" : "software.awssdk",
+          |            "artifactId" : {
+          |              "name" : "s3",
+          |              "maybeCrossName" : null
+          |            },
+          |            "version" : "2.100.0",
+          |            "sbtVersion" : null,
+          |            "scalaVersion" : null,
+          |            "configurations" : null
+          |          }
+          |        ],
+          |        "newerVersions" : [
+          |          "2.200.0"
+          |        ],
+          |        "newerGroupId" : null
+          |      }
+          |    },
+          |    "state" : "open",
+          |    "entryCreatedAt" : 1581969227183
+          |  }
+          |}""".stripMargin
+    val versionsFile =
+      config.workspace / "store/versions/v2/https/foobar.org/maven2/software/awssdk/s3/versions.json"
+    val versionsContent =
+      s"""|{
+          |  "updatedAt" : 9999999999999,
+          |  "versions" : [
+          |    "2.100.0",
+          |    "2.200.0",
+          |    "2.300.0"
+          |  ]
+          |}
+          |""".stripMargin
+    val initial = MockState.empty
+      .addFiles(pullRequestsFile -> pullRequestsContent, versionsFile -> versionsContent)
+      .unsafeRunSync()
+    val data = RepoData(repo, repoCache, repoCache.maybeRepoConfig.getOrElse(RepoConfig.empty))
+    val state = pruningAlg.needsAttention(data).runS(initial).unsafeRunSync()
+    val expected = initial.copy(
+      trace = Vector(
+        Log(s"Find updates for ${repo.show}"),
+        Cmd("read", versionsFile.toString),
+        Cmd("read", versionsFile.toString),
+        Log("Found 1 update:\n  software.awssdk:s3 : 2.100.0 -> 2.300.0"),
+        Log(
+          "pruning-test/repo3 is outdated:\n  new version: software.awssdk:s3 : 2.100.0 -> 2.300.0"
+        )
+      )
+    )
+    assertEquals(state, expected)
+  }
+
+  test("needsAttention: dependency can have artifactId and version") {
+    val repo = Repo("pruning-test", "repo3")
+    val Right(repoCache) = decode[RepoCache](
+      s"""|{
+          |  "sha1": "12def27a837ba6dc9e17406cbbe342fba3527c14",
+          |  "dependencyInfos" : [
+          |    {
+          |      "value" : [
+          |        {
+          |          "dependency" : {
+          |            "groupId" : "software.awssdk",
+          |            "artifactId" : {
+          |              "name" : "s3",
+          |              "maybeCrossName" : null
+          |            },
+          |            "version" : "2.100.0",
+          |            "sbtVersion" : null,
+          |            "scalaVersion" : null,
+          |            "configurations" : null
+          |          },
+          |          "filesContainingVersion": [
+          |            "build.sbt"
+          |          ]
+          |        }
+          |      ],
+          |      "resolvers" : [
+          |        {
+          |          "MavenRepository" : {
+          |            "name" : "public",
+          |            "location" : "https://foobar.org/maven2/"
+          |          }
+          |        }
+          |      ]
+          |    }
+          |  ],
+          |  "maybeRepoConfig": {
+          |    "dependencyOverrides": [
+          |      {
+          |        "dependency": { "groupId": "software.awssdk", "artifactId": "ignored", "version": "2." },
+          |        "pullRequests": { "frequency": "30 days" }
+          |      },
+          |      {
+          |        "dependency": { "groupId": "software.awssdk", "artifactId": "s3", "version": "1." },
+          |        "pullRequests": { "frequency": "4 days" }
+          |      }
+          |    ],
+          |    "pullRequests": {
+          |      "frequency": "@asap"
+          |    }
+          |  }
+          |}""".stripMargin
+    )
+    val timestampNow = Instant.now().toEpochMilli
+    val pullRequestsFile =
+      config.workspace / s"store/pull_requests/v2/github/${repo.toPath}/pull_requests.json"
+    val pullRequestsContent =
+      s"""|{
+          |  "https://github.com/${repo.toPath}/pull/27" : {
+          |    "baseSha1" : "12def27a837ba6dc9e17406cbbe342fba3527c14",
+          |    "update" : {
+          |      "Single" : {
+          |        "crossDependency" : [
+          |          {
+          |            "groupId" : "software.awssdk",
+          |            "artifactId" : {
+          |              "name" : "s3",
+          |              "maybeCrossName" : null
+          |            },
+          |            "version" : "2.100.0",
+          |            "sbtVersion" : null,
+          |            "scalaVersion" : null,
+          |            "configurations" : null
+          |          }
+          |        ],
+          |        "newerVersions" : [
+          |          "2.200.0"
+          |        ],
+          |        "newerGroupId" : null
+          |      }
+          |    },
+          |    "state" : "open",
+          |    "entryCreatedAt" : $timestampNow
+          |  }
+          |}""".stripMargin
+    val versionsFile =
+      config.workspace / "store/versions/v2/https/foobar.org/maven2/software/awssdk/s3/versions.json"
+    val versionsContent =
+      s"""|{
+          |  "updatedAt" : 9999999999999,
+          |  "versions" : [
+          |    "2.100.0",
+          |    "2.200.0",
+          |    "2.300.0"
+          |  ]
+          |}
+          |""".stripMargin
+    val initial = MockState.empty
+      .addFiles(pullRequestsFile -> pullRequestsContent, versionsFile -> versionsContent)
+      .unsafeRunSync()
+    val data = RepoData(repo, repoCache, repoCache.maybeRepoConfig.getOrElse(RepoConfig.empty))
+    val state = pruningAlg.needsAttention(data).runS(initial).unsafeRunSync()
+    val expected = initial.copy(
+      trace = Vector(
+        Log(s"Find updates for ${repo.show}"),
+        Cmd("read", versionsFile.toString),
+        Cmd("read", versionsFile.toString),
+        Log("Found 1 update:\n  software.awssdk:s3 : 2.100.0 -> 2.300.0"),
+        Log(
+          s"${repo.show} is outdated:\n  new version: software.awssdk:s3 : 2.100.0 -> 2.300.0"
+        )
+      )
+    )
+    assertEquals(state, expected)
+  }
 }