diff --git a/modules/core/src/main/scala/org/scalasteward/core/coursier/VersionsCache.scala b/modules/core/src/main/scala/org/scalasteward/core/coursier/VersionsCache.scala index 59d7fe90f8..a6c9d53225 100644 --- a/modules/core/src/main/scala/org/scalasteward/core/coursier/VersionsCache.scala +++ b/modules/core/src/main/scala/org/scalasteward/core/coursier/VersionsCache.scala @@ -18,12 +18,13 @@ package org.scalasteward.core.coursier import cats.implicits.* import cats.{MonadThrow, Parallel} -import io.circe.generic.semiauto.deriveCodec -import io.circe.{Codec, KeyEncoder} -import org.scalasteward.core.coursier.VersionsCache.{Key, Value} +import io.circe.generic.semiauto.{deriveCodec, deriveDecoder, deriveEncoder} +import io.circe.{Codec, Decoder, Encoder, KeyEncoder} +import org.scalasteward.core.coursier.VersionsCache.{Key, Value, VersionWithFirstSeen} import org.scalasteward.core.data.{Dependency, Resolver, Scope, Version} import org.scalasteward.core.persistence.KeyValueStore import org.scalasteward.core.util.{DateTimeAlg, Timestamp} + import scala.concurrent.duration.FiniteDuration final class VersionsCache[F[_]]( @@ -35,16 +36,19 @@ final class VersionsCache[F[_]]( parallel: Parallel[F], F: MonadThrow[F] ) { - def getVersions(dependency: Scope.Dependency, maxAge: Option[FiniteDuration]): F[List[Version]] = + def getVersions( + dependency: Scope.Dependency, + maxAge: Option[FiniteDuration] + ): F[List[VersionWithFirstSeen]] = dependency.resolvers .parFlatTraverse(getVersionsImpl(dependency.value, _, maxAge.getOrElse(cacheTtl))) - .map(_.distinct.sorted) + .map(_.distinct.sortBy(_.version)) private def getVersionsImpl( dependency: Dependency, resolver: Resolver, maxAge: FiniteDuration - ): F[List[Version]] = + ): F[List[VersionWithFirstSeen]] = dateTimeAlg.currentTimestamp.flatMap { now => val key = Key(dependency, resolver) store.get(key).flatMap { @@ -53,7 +57,18 @@ final class VersionsCache[F[_]]( case maybeValue => coursierAlg.getVersions(dependency, resolver).attempt.flatMap { case Right(versions) => - store.put(key, Value(now, versions, None)).as(versions) + val existingFirstSeenByVersion: Map[Version, Timestamp] = + maybeValue + .map(_.versions.flatMap(vwfs => vwfs.firstSeen.map(vwfs.version -> _)).toMap) + .getOrElse(Map.empty) + val updatedVersionsWithFirstSeen = + versions.map(v => + VersionWithFirstSeen(v, Some(existingFirstSeenByVersion.getOrElse(v, now))) + ) + + store + .put(key, Value(now, updatedVersionsWithFirstSeen, None)) + .as(updatedVersionsWithFirstSeen) case Left(throwable) => val versions = maybeValue.map(_.versions).getOrElse(List.empty) store.put(key, Value(now, versions, Some(throwable.toString))).as(versions) @@ -79,7 +94,7 @@ object VersionsCache { final case class Value( updatedAt: Timestamp, - versions: List[Version], + versions: List[VersionWithFirstSeen], maybeError: Option[String] ) { def maxAgeFactor: Long = @@ -90,4 +105,18 @@ object VersionsCache { implicit val valueCodec: Codec[Value] = deriveCodec } + + final case class VersionWithFirstSeen( + version: Version, + firstSeen: Option[Timestamp] + ) + + object VersionWithFirstSeen { + implicit val valueEncoder: Encoder[VersionWithFirstSeen] = deriveEncoder + implicit val valueDecoder: Decoder[VersionWithFirstSeen] = { + val legacyVersionStringDecoder = Decoder[Version].map(VersionWithFirstSeen(_, None)) + + deriveDecoder[VersionWithFirstSeen].or(legacyVersionStringDecoder) + } + } } 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 d8aabe72ba..94ed47e3d9 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 @@ -22,6 +22,7 @@ import io.circe.{Decoder, Encoder} import org.scalasteward.core.repoconfig.PullRequestGroup import org.scalasteward.core.util import org.scalasteward.core.util.Nel +import org.scalasteward.core.coursier.VersionsCache.VersionWithFirstSeen sealed trait Update { @@ -76,17 +77,14 @@ object Update { s"$groupId:$artifacts : $versions" } - def withNewerVersions(versions: Nel[Version]): Update.Single = this match { - case s @ ForArtifactId(_, _, _, _) => - s.copy(newerVersions = versions) - case ForGroupId(forArtifactIds) => - ForGroupId(forArtifactIds.map(_.copy(newerVersions = versions))) - } + def supersedes(that: Update.Single): Boolean = + this.groupAndMainArtifactId == that.groupAndMainArtifactId + && this.nextVersion > that.nextVersion } final case class ForArtifactId( crossDependency: CrossDependency, - newerVersions: Nel[Version], + newerVersions: Nel[VersionWithFirstSeen], newerGroupId: Option[GroupId] = None, newerArtifactId: Option[String] = None ) extends Single { 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 672770e546..8fd44d4438 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 @@ -71,9 +71,7 @@ final class PullRequestRepository[F[_]](kvStore: KeyValueStore[F, Repo, Map[Uri, kvStore.getOrElse(repo, Map.empty).map { _.collect { case (url, Entry(baseSha1, u: Update.Single, state, _, number, updateBranch)) - if state === PullRequestState.Open && - u.withNewerVersions(update.newerVersions) === update && - u.nextVersion < update.nextVersion => + if state === PullRequestState.Open && update.supersedes(u) => for { number <- number branch = updateBranch.getOrElse(git.branchFor(u, repo.branch)) diff --git a/modules/core/src/main/scala/org/scalasteward/core/persistence/JsonKeyValueStore.scala b/modules/core/src/main/scala/org/scalasteward/core/persistence/JsonKeyValueStore.scala index 8bc5733c44..ac42833cc0 100644 --- a/modules/core/src/main/scala/org/scalasteward/core/persistence/JsonKeyValueStore.scala +++ b/modules/core/src/main/scala/org/scalasteward/core/persistence/JsonKeyValueStore.scala @@ -54,8 +54,11 @@ final class JsonKeyValueStore[F[_], K, V](storeRoot: File, name: String)(implici } } - private def jsonFile(key: K): File = - storeRoot / keyEncoder(key) / s"$name.json" + private def jsonFile(key: K): File = { + val file = storeRoot / keyEncoder(key) / s"$name.json" + println(s"file is ${file}") + file + } } object JsonKeyValueStore { diff --git a/modules/core/src/main/scala/org/scalasteward/core/repoconfig/CooldownConfig.scala b/modules/core/src/main/scala/org/scalasteward/core/repoconfig/CooldownConfig.scala new file mode 100644 index 0000000000..89380db312 --- /dev/null +++ b/modules/core/src/main/scala/org/scalasteward/core/repoconfig/CooldownConfig.scala @@ -0,0 +1,37 @@ +/* + * Copyright 2018-2025 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, Decoder, Encoder} +import io.circe.generic.semiauto.deriveCodec +import scala.concurrent.duration.FiniteDuration +import cats.syntax.either.* + +import org.scalasteward.core.util.dateTime.parseFiniteDuration + +final case class CooldownConfig( + age: FiniteDuration, + artifacts: List[UpdatePattern] = List.empty +) + +object CooldownConfig { + implicit val codec: Codec[CooldownConfig] = deriveCodec + implicit val finiteDurationDecoder: Decoder[FiniteDuration] = + Decoder[String].emap(s => parseFiniteDuration(s).leftMap(_.toString)) + implicit val finiteDurationEncoder: Encoder[FiniteDuration] = + Encoder[String].contramap(_.toString) +} diff --git a/modules/core/src/main/scala/org/scalasteward/core/repoconfig/UpdatePattern.scala b/modules/core/src/main/scala/org/scalasteward/core/repoconfig/UpdatePattern.scala index 149d52d8f2..492c1e391c 100644 --- a/modules/core/src/main/scala/org/scalasteward/core/repoconfig/UpdatePattern.scala +++ b/modules/core/src/main/scala/org/scalasteward/core/repoconfig/UpdatePattern.scala @@ -20,6 +20,7 @@ import cats.syntax.all.* import io.circe.Codec import io.circe.generic.semiauto.* import org.scalasteward.core.data.{GroupId, Update, Version} +import org.scalasteward.core.coursier.VersionsCache.VersionWithFirstSeen final case class UpdatePattern( groupId: GroupId, @@ -32,18 +33,22 @@ final case class UpdatePattern( object UpdatePattern { final case class MatchResult( byArtifactId: List[UpdatePattern], - filteredVersions: List[Version] + filteredVersions: List[VersionWithFirstSeen] ) def findMatch( patterns: List[UpdatePattern], update: Update.ForArtifactId, - include: Boolean + include: Boolean, + versionPredicate: VersionWithFirstSeen => Boolean = _ => true ): MatchResult = { val byGroupId = patterns.filter(_.groupId === update.groupId) val byArtifactId = byGroupId.filter(_.artifactId.forall(_ === update.artifactId.name)) val filteredVersions = update.newerVersions.filter(newVersion => - byArtifactId.exists(_.version.forall(_.matches(newVersion.value))) === include + (byArtifactId.exists(_.version.forall(_.matches(newVersion.value))) && versionPredicate( + newVersion + )) + === include ) MatchResult(byArtifactId, filteredVersions) } diff --git a/modules/core/src/main/scala/org/scalasteward/core/repoconfig/UpdatesConfig.scala b/modules/core/src/main/scala/org/scalasteward/core/repoconfig/UpdatesConfig.scala index d521373b61..75b424a201 100644 --- a/modules/core/src/main/scala/org/scalasteward/core/repoconfig/UpdatesConfig.scala +++ b/modules/core/src/main/scala/org/scalasteward/core/repoconfig/UpdatesConfig.scala @@ -40,7 +40,8 @@ final case class UpdatesConfig( private val ignore: Option[List[UpdatePattern]] = None, private val retracted: Option[List[RetractedArtifact]] = None, limit: Option[NonNegInt] = UpdatesConfig.defaultLimit, - private val fileExtensions: Option[List[String]] = None + private val fileExtensions: Option[List[String]] = None, + private val cooldown: Option[List[CooldownConfig]] = None ) { private[repoconfig] def pinOrDefault: List[UpdatePattern] = pin.getOrElse(Nil) diff --git a/modules/core/src/test/resources/versions-cache-value-with-first-seen.json b/modules/core/src/test/resources/versions-cache-value-with-first-seen.json new file mode 100644 index 0000000000..93c0d72224 --- /dev/null +++ b/modules/core/src/test/resources/versions-cache-value-with-first-seen.json @@ -0,0 +1,8 @@ +{ + "updatedAt": 10002, + "versions": [ + { "version": "1.0.0", "firstSeen": 10000 }, + { "version": "1.0.1", "firstSeen": 10001 } + ], + "maybeError": null +} diff --git a/modules/core/src/test/resources/versions-cache-value-without-first-seen.json b/modules/core/src/test/resources/versions-cache-value-without-first-seen.json new file mode 100644 index 0000000000..c5b2f305b4 --- /dev/null +++ b/modules/core/src/test/resources/versions-cache-value-without-first-seen.json @@ -0,0 +1,5 @@ +{ + "updatedAt": 1000, + "versions": ["1.0.0", "1.0.1"], + "maybeError": null +} diff --git a/modules/core/src/test/scala/org/scalasteward/core/coursier/VersionsCacheTest.scala b/modules/core/src/test/scala/org/scalasteward/core/coursier/VersionsCacheTest.scala new file mode 100644 index 0000000000..faff6207ec --- /dev/null +++ b/modules/core/src/test/scala/org/scalasteward/core/coursier/VersionsCacheTest.scala @@ -0,0 +1,37 @@ +package org.scalasteward.core.coursier + +import io.circe.parser +import munit.FunSuite +import org.scalasteward.core.coursier.VersionsCache.{Value, VersionWithFirstSeen} +import org.scalasteward.core.data.Version +import org.scalasteward.core.util.Timestamp + +import scala.io.Source + +class VersionsCacheTest extends FunSuite { + test("version cache deserialisation without first seen") { + val input = Source.fromResource("versions-cache-value-without-first-seen.json").mkString + val expected = Value( + Timestamp(1000), + List( + VersionWithFirstSeen(Version("1.0.0"), None), + VersionWithFirstSeen(Version("1.0.1"), None) + ), + None + ) + assertEquals(parser.decode[Value](input), Right(expected)) + } + + test("version cache deserialisation with first seen") { + val input = Source.fromResource("versions-cache-value-with-first-seen.json").mkString + val expected = Value( + Timestamp(10002), + List( + VersionWithFirstSeen(Version("1.0.0"), Some(Timestamp(10000))), + VersionWithFirstSeen(Version("1.0.1"), Some(Timestamp(10001))) + ), + None + ) + assertEquals(parser.decode[Value](input), Right(expected)) + } +}