From e6131eba5b8372b9ee6cf82ee602ed30d4072016 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marco=20Zu=CC=88hlke?= Date: Sun, 24 Nov 2024 21:59:08 +0100 Subject: [PATCH] Retract previously opened Pull Request The `updates.retraced` section of the `scala-steward.conf` allows to declare updates that should be retracted. Each entry must have a `reason, a `doc` URL and a list of dependency patterns. Example: ``` updates.retracted = [ { reason = "Ignore version 3.6.0 as it is abandoned due to broken compatibility", doc = "https://contributors.scala-lang.org/t/broken-scala-3-6-0-release/6792", artifacts = [ { groupId = "org.scala-lang", artifactId = "scala3-compiler", version = { exact = "3.6.0" } } ] } ] ``` Retraction of Pull Request is only possible, if the workspace is persisted correctly. (https://github.com/scala-steward-org/scala-steward/blob/main/docs/faq.md#why-doesnt-self-hosted-scala-steward-close-obsolete-prs) Fixes: #3445 --- docs/repo-specific-configuration.md | 13 ++++++ .../main/resources/default.scala-steward.conf | 20 ++++++++ .../core/application/StewardAlg.scala | 14 ++++-- .../core/nurture/NurtureAlg.scala | 28 ++++++++++- .../core/nurture/PullRequestRepository.scala | 24 ++++++++++ .../core/repoconfig/RetractedArtifact.scala | 46 +++++++++++++++++++ .../core/repoconfig/UpdatesConfig.scala | 2 + .../nurture/PullRequestRepositoryTest.scala | 28 +++++++++++ .../repoconfig/RetractedArtifactTest.scala | 29 ++++++++++++ .../docs/mdoc/repo-specific-configuration.md | 13 ++++++ 10 files changed, 211 insertions(+), 6 deletions(-) create mode 100644 modules/core/src/main/scala/org/scalasteward/core/repoconfig/RetractedArtifact.scala create mode 100644 modules/core/src/test/scala/org/scalasteward/core/repoconfig/RetractedArtifactTest.scala diff --git a/docs/repo-specific-configuration.md b/docs/repo-specific-configuration.md index 9555ac167e..cf950cc22f 100644 --- a/docs/repo-specific-configuration.md +++ b/docs/repo-specific-configuration.md @@ -104,6 +104,19 @@ updates.pin = [ { groupId = "com.example", artifactId="foo", version = "1.1." } # Defaults to empty `[]` which mean Scala Steward will not ignore dependencies. updates.ignore = [ { groupId = "org.acme", artifactId="foo", version = "1.0" } ] +# The dependencies which match the given pattern are retracted: Their existing Pull-Request will be closed. +# +# Each entry must have a `reason, a `doc` URL and a list of dependency patterns. +updates.retracted = [ + { + reason = "Ignore version 3.6.0 as it is abandoned due to broken compatibility", + doc = "https://contributors.scala-lang.org/t/broken-scala-3-6-0-release/6792", + artifacts = [ + { groupId = "org.scala-lang", artifactId = "scala3-compiler", version = { exact = "3.6.0" } } + ] + } +] + # The dependencies which match the given patterns are allowed to be updated to pre-release from stable. # This also implies, that it will be allowed for snapshot versions to be updated to snapshots of different series. # diff --git a/modules/core/src/main/resources/default.scala-steward.conf b/modules/core/src/main/resources/default.scala-steward.conf index 0e7d199f87..a554b3bfd0 100644 --- a/modules/core/src/main/resources/default.scala-steward.conf +++ b/modules/core/src/main/resources/default.scala-steward.conf @@ -278,3 +278,23 @@ updates.ignore = [ { groupId = "org.tpolecat", artifactId="doobie-hikari", version="1.0.0-RC6" }, { groupId = "org.tpolecat", artifactId="doobie-postgres-circe", version="1.0.0-RC6" }, ] + +updates.retracted = [ + { + reason = "Ignore version 3.6.0 as it is abandoned due to broken compatibility", + doc = "https://contributors.scala-lang.org/t/broken-scala-3-6-0-release/6792", + artifacts = [ + { groupId = "org.scala-lang", artifactId = "scala3-compiler", version = { exact = "3.6.0" } }, + { groupId = "org.scala-lang", artifactId = "scala3-library", version = { exact = "3.6.0" } }, + { groupId = "org.scala-lang", artifactId = "scala3-library_sjs1", version = { exact = "3.6.0" } }, + { groupId = "org.scala-lang", artifactId = "tasty-core", version = { exact = "3.6.0" } }, + { groupId = "org.scala-lang", artifactId = "scala2-library-cc-tasty-experimental", version = { exact = "3.6.0" } }, + { groupId = "org.scala-lang", artifactId = "scala2-library-tasty-experimental", version = { exact = "3.6.0" } }, + { groupId = "org.scala-lang", artifactId = "scala3-language-server", version = { exact = "3.6.0" } }, + { groupId = "org.scala-lang", artifactId = "scala3-presentation-compiler", version = { exact = "3.6.0" } }, + { groupId = "org.scala-lang", artifactId = "scala3-staging", version = { exact = "3.6.0" } }, + { groupId = "org.scala-lang", artifactId = "scala3-tasty-inspector", version = { exact = "3.6.0" } }, + { groupId = "org.scala-lang", artifactId = "scaladoc", version = { exact = "3.6.0" } }, + ] + } +] \ No newline at end of file diff --git a/modules/core/src/main/scala/org/scalasteward/core/application/StewardAlg.scala b/modules/core/src/main/scala/org/scalasteward/core/application/StewardAlg.scala index 42b00015a6..158d2d49f8 100644 --- a/modules/core/src/main/scala/org/scalasteward/core/application/StewardAlg.scala +++ b/modules/core/src/main/scala/org/scalasteward/core/application/StewardAlg.scala @@ -50,11 +50,15 @@ final class StewardAlg[F[_]](config: Config)(implicit logger.infoTotalTime(label) { logger.attemptError.label(util.string.lineLeftRight(label), Some(label)) { F.guarantee( - repoCacheAlg.checkCache(repo).flatMap { case (data, fork) => - pruningAlg.needsAttention(data).flatMap { - _.traverse_(states => nurtureAlg.nurture(data, fork, states.map(_.update))) - } - }, + for { + dataAndFork <- repoCacheAlg.checkCache(repo) + (data, fork) = dataAndFork + _ <- nurtureAlg.closeRetractedPullRequests(data) + states <- pruningAlg.needsAttention(data) + result <- states.traverse_(states => + nurtureAlg.nurture(data, fork, states.map(_.update)) + ) + } yield result, gitAlg.removeClone(repo) ) } 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 2ef236245d..ea07b921bc 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 @@ -28,7 +28,7 @@ import org.scalasteward.core.forge.data.NewPullRequestData.{filterLabels, labels import org.scalasteward.core.forge.data._ import org.scalasteward.core.forge.{ForgeApiAlg, ForgeRepoAlg} import org.scalasteward.core.git.{Branch, Commit, GitAlg} -import org.scalasteward.core.repoconfig.PullRequestUpdateStrategy +import org.scalasteward.core.repoconfig.{PullRequestUpdateStrategy, RetractedArtifact} import org.scalasteward.core.util.logger.LoggerOps import org.scalasteward.core.util.{Nel, UrlChecker} import org.scalasteward.core.{git, util} @@ -306,4 +306,30 @@ final class NurtureAlg[F[_]](config: ForgeCfg)(implicit requestData <- preparePullRequest(data, edits) _ <- forgeApiAlg.updatePullRequest(number: PullRequestNumber, data.repo, requestData) } yield result + + def closeRetractedPullRequests(data: RepoData): F[Unit] = + pullRequestRepository + .getRetractedPullRequests(data.repo, data.config.updates.retracted) + .flatMap { + _.traverse_ { case (oldPr, retractedArtifact) => + closeRetractedPullRequest(data, oldPr, retractedArtifact) + } + } + + private def closeRetractedPullRequest( + data: RepoData, + oldPr: PullRequestData[Id], + retractedArtifact: RetractedArtifact + ): F[Unit] = + logger.attemptWarn.label_( + s"Closing retracted PR ${oldPr.url.renderString} for ${oldPr.update.show} because of '${retractedArtifact.reason}'" + ) { + for { + _ <- pullRequestRepository.changeState(data.repo, oldPr.url, PullRequestState.Closed) + comment = retractedArtifact.retractionMsg + _ <- forgeApiAlg.commentPullRequest(data.repo, oldPr.number, comment) + _ <- forgeApiAlg.closePullRequest(data.repo, oldPr.number) + _ <- deleteRemoteBranch(data.repo, oldPr.updateBranch) + } yield F.unit + } } 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 9ddc71b4ce..1b91ce6487 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 @@ -27,6 +27,7 @@ import org.scalasteward.core.git import org.scalasteward.core.git.{Branch, Sha1} import org.scalasteward.core.nurture.PullRequestRepository.Entry import org.scalasteward.core.persistence.KeyValueStore +import org.scalasteward.core.repoconfig.RetractedArtifact import org.scalasteward.core.update.UpdateAlg import org.scalasteward.core.util.{DateTimeAlg, Timestamp} @@ -80,6 +81,29 @@ final class PullRequestRepository[F[_]](kvStore: KeyValueStore[F, Repo, Map[Uri, }.flatten.toList.sortBy(_.number.value) } + def getRetractedPullRequests( + repo: Repo, + allRetractedArtifacts: List[RetractedArtifact] + ): F[List[(PullRequestData[Id], RetractedArtifact)]] = + kvStore.getOrElse(repo, Map.empty).map { pullRequets: Map[Uri, Entry] => + pullRequets.flatMap { + case ( + url, + Entry(baseSha1, u: Update.Single, PullRequestState.Open, _, number, updateBranch) + ) => + for { + prNumber <- number + retractedArtifact <- allRetractedArtifacts.find(_.isRetracted(u)) + } yield { + val branch = updateBranch.getOrElse(git.branchFor(u, repo.branch)) + val data = + PullRequestData[Id](url, baseSha1, u, PullRequestState.Open, prNumber, branch) + (data, retractedArtifact) + } + case _ => Map.empty + }.toList + } + def findLatestPullRequest( repo: Repo, crossDependency: CrossDependency, diff --git a/modules/core/src/main/scala/org/scalasteward/core/repoconfig/RetractedArtifact.scala b/modules/core/src/main/scala/org/scalasteward/core/repoconfig/RetractedArtifact.scala new file mode 100644 index 0000000000..b2e2657e94 --- /dev/null +++ b/modules/core/src/main/scala/org/scalasteward/core/repoconfig/RetractedArtifact.scala @@ -0,0 +1,46 @@ +/* + * Copyright 2018-2023 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.semiauto.deriveCodec +import org.scalasteward.core.data.Update + +final case class RetractedArtifact( + reason: String, + doc: String, + artifacts: List[UpdatePattern] = List.empty +) { + def isRetracted(updateSingle: Update.Single): Boolean = + updateSingle.forArtifactIds.exists { updateForArtifactId => + UpdatePattern + .findMatch(artifacts, updateForArtifactId, include = true) + .filteredVersions + .nonEmpty + } + + def retractionMsg: String = + s"""|Retracted because of: ${reason}. + | + |Documentation: ${doc} + |""".stripMargin.trim +} + +object RetractedArtifact { + implicit val retractedPatternCodec: Codec[RetractedArtifact] = + deriveCodec +} 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 95cd4f4d91..a2521ce487 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 @@ -42,6 +42,7 @@ final case class UpdatesConfig( allow: List[UpdatePattern] = List.empty, allowPreReleases: List[UpdatePattern] = List.empty, ignore: List[UpdatePattern] = List.empty, + retracted: List[RetractedArtifact] = List.empty, limit: Option[NonNegInt] = defaultLimit, fileExtensions: Option[List[String]] = None ) { @@ -124,6 +125,7 @@ object UpdatesConfig { allow = mergeAllow(x.allow, y.allow), allowPreReleases = mergeAllow(x.allowPreReleases, y.allowPreReleases), ignore = mergeIgnore(x.ignore, y.ignore), + retracted = x.retracted ::: y.retracted, limit = x.limit.orElse(y.limit), fileExtensions = mergeFileExtensions(x.fileExtensions, y.fileExtensions) ) 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 7633fec824..1d8c42e403 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 @@ -14,6 +14,7 @@ import org.scalasteward.core.mock.MockContext.context.pullRequestRepository import org.scalasteward.core.mock.MockState.TraceEntry import org.scalasteward.core.mock.MockState.TraceEntry.Cmd import org.scalasteward.core.mock.{MockEff, MockState} +import org.scalasteward.core.repoconfig.{RetractedArtifact, UpdatePattern, VersionPattern} import org.scalasteward.core.util.Nel import java.util.concurrent.atomic.AtomicInteger @@ -122,6 +123,33 @@ class PullRequestRepositoryTest extends FunSuite { assertEquals(result, List.empty) } + test("getRetractedPullRequests with no retractions defined") { + val (_, obtained) = beforeAndAfterPRCreation(portableScala) { repo => + pullRequestRepository.getRetractedPullRequests(repo, List.empty) + } + assertEquals(obtained, List.empty[(PullRequestData[Id], RetractedArtifact)]) + } + + test("getRetractedPullRequests with retractions") { + val retractedPortableScala = RetractedArtifact( + "a reason", + "doc URI", + List( + UpdatePattern( + "org.portable-scala".g, + Some("sbt-scalajs-crossproject"), + Some(VersionPattern(exact = Some("1.0.0"))) + ) + ) + ) + val (_, obtained) = beforeAndAfterPRCreation(portableScala) { repo => + pullRequestRepository.getRetractedPullRequests(repo, List(retractedPortableScala)) + } + assertEquals(obtained.size, 1) + assertEquals(obtained.head._1.update, portableScala) + assertEquals(obtained.head._2, retractedPortableScala) + } + test("findLatestPullRequest ignores grouped updates") { val (_, result) = beforeAndAfterPRCreation(groupedUpdate(portableScala)) { repo => pullRequestRepository.findLatestPullRequest(repo, portableScala.crossDependency, "1.0.0".v) diff --git a/modules/core/src/test/scala/org/scalasteward/core/repoconfig/RetractedArtifactTest.scala b/modules/core/src/test/scala/org/scalasteward/core/repoconfig/RetractedArtifactTest.scala new file mode 100644 index 0000000000..b5866b265f --- /dev/null +++ b/modules/core/src/test/scala/org/scalasteward/core/repoconfig/RetractedArtifactTest.scala @@ -0,0 +1,29 @@ +package org.scalasteward.core.repoconfig + +import org.scalasteward.core.TestSyntax._ + +import munit.FunSuite + +class RetractedArtifactTest extends FunSuite { + private val retractedArtifact = RetractedArtifact( + "a reason", + "doc URI", + List( + UpdatePattern( + "org.portable-scala".g, + Some("sbt-scalajs-crossproject"), + Some(VersionPattern(exact = Some("1.0.0"))) + ) + ) + ) + + test("isRetracted") { + val update = ("org.portable-scala".g % "sbt-scalajs-crossproject".a % "0.9.2" %> "1.0.0").single + assert(retractedArtifact.isRetracted(update)) + } + + test("not isRetracted") { + val update = ("org.portable-scala".g % "sbt-scalajs-crossproject".a % "0.9.2" %> "0.9.3").single + assert(!retractedArtifact.isRetracted(update)) + } +} diff --git a/modules/docs/mdoc/repo-specific-configuration.md b/modules/docs/mdoc/repo-specific-configuration.md index 99eeef0e5f..5d9adf96ff 100644 --- a/modules/docs/mdoc/repo-specific-configuration.md +++ b/modules/docs/mdoc/repo-specific-configuration.md @@ -110,6 +110,19 @@ updates.pin = [ { groupId = "com.example", artifactId="foo", version = "1.1." } # Defaults to empty `[]` which mean Scala Steward will not ignore dependencies. updates.ignore = [ { groupId = "org.acme", artifactId="foo", version = "1.0" } ] +# The dependencies which match the given pattern are retracted: Their existing Pull-Request will be closed. +# +# Each entry must have a `reason, a `doc` URL and a list of dependency patterns. +updates.retracted = [ + { + reason = "Ignore version 3.6.0 as it is abandoned due to broken compatibility", + doc = "https://contributors.scala-lang.org/t/broken-scala-3-6-0-release/6792", + artifacts = [ + { groupId = "org.scala-lang", artifactId = "scala3-compiler", version = { exact = "3.6.0" } } + ] + } +] + # The dependencies which match the given patterns are allowed to be updated to pre-release from stable. # This also implies, that it will be allowed for snapshot versions to be updated to snapshots of different series. #