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

Issue:
Closing pull-requests for retracted versions
It happens to the best developers that they release a a new version premature. Scala 3.6.0 was just the last example. 
The https://github.com/scala-steward-org/scala-steward/blob/main/modules/core/src/main/resources/default.scala-steward.conf lists multiple blocked artifacts.

The idea is to close pull requests of artifacts that get listed in `updates.retracted` and/or leave at lest a comment that this version shouldn't be used. The affected should be stored in the `PullRequestRepository`.

@scala-steward-org/core @WojciechMazur @SethTisue @Kordyjan

Pull request description:
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

Patch:
diff --git a/docs/repo-specific-configuration.md b/docs/repo-specific-configuration.md
index 9555ac167e..7fa70064f3 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..e8f461491e 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)
+            statesO <- pruningAlg.needsAttention(data)
+            result <- statesO.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..6762c0cd02 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 =>
+      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..a29486734d
--- /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"""|PR retracted because of: ${reason}.
+        |
+        |Documentation: ${doc}
+        |""".stripMargin
+}
+
+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/docs/mdoc/repo-specific-configuration.md b/modules/docs/mdoc/repo-specific-configuration.md
index 99eeef0e5f..5484468f1f 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.
 #


Test patch:
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..53683770e0 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,51 @@ 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("getRetractedPullRequests with retractions for different version") {
+    val retractedPortableScala = RetractedArtifact(
+      "a reason",
+      "doc URI",
+      List(
+        UpdatePattern(
+          "org.portable-scala".g,
+          Some("sbt-scalajs-crossproject"),
+          Some(VersionPattern(exact = Some("2.0.0")))
+        )
+      )
+    )
+    val (_, obtained) = beforeAndAfterPRCreation(portableScala) { repo =>
+      pullRequestRepository.getRetractedPullRequests(repo, List(retractedPortableScala))
+    }
+    assertEquals(obtained, List.empty[(PullRequestData[Id], RetractedArtifact)])
+  }
+
   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))
+  }
+}