Repository: scala-steward-org/scala-steward
Pull request #1667 (2020-10-16 03:05:55)

Close obsolete pull requests in the NurtureAlg
I've only test that against GitLab as I said in: https://github.com/scala-steward-org/scala-steward/issues/119#issuecomment-709653048

All 4 server implement this with a PUT/PATCH to the pull request (merge request for Gitlab API) endpoint of the given pull request. There are some distinction in how the update in serialized but, all in all, it's quite similar:

```
https://docs.gitlab.com/ee/api/merge_requests.html#update-mr
GITLAB => {"state_event": "close"}

https://docs.github.com/en/free-pro-team@latest/rest/reference/pulls#update-a-pull-request
GITHUB => {"state": "closed"}


https://developer.atlassian.com/bitbucket/api/2/reference/resource/repositories/%7Bworkspace%7D/%7Brepo_slug%7D/pullrequests/%7Bpull_request_id%7D#put
https://docs.atlassian.com/bitbucket-server/rest/6.6.1/bitbucket-rest.html#idp271
BITBUCKET => {"state": "declined"}
```


Patch:
diff --git a/modules/core/src/main/scala/org/scalasteward/core/bitbucket/Url.scala b/modules/core/src/main/scala/org/scalasteward/core/bitbucket/Url.scala
index a7e4f513..b612a721 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/bitbucket/Url.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/bitbucket/Url.scala
@@ -30,6 +30,9 @@ private[bitbucket] class Url(apiHost: Uri) {
   def pullRequests(rep: Repo): Uri =
     repo(rep) / "pullrequests"
 
+  def pullRequest(rep: Repo, id: Int): Uri =
+    repo(rep) / "pullrequests" / id.toString
+
   def branch(rep: Repo, branch: Branch): Uri =
     repo(rep) / "refs" / "branches" / branch.name
 
diff --git a/modules/core/src/main/scala/org/scalasteward/core/bitbucket/http4s/Http4sBitbucketApiAlg.scala b/modules/core/src/main/scala/org/scalasteward/core/bitbucket/http4s/Http4sBitbucketApiAlg.scala
index 89a6f53e..fb3c1562 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/bitbucket/http4s/Http4sBitbucketApiAlg.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/bitbucket/http4s/Http4sBitbucketApiAlg.scala
@@ -88,4 +88,11 @@ class Http4sBitbucketApiAlg[F[_]: Sync](
     client
       .get[Page[PullRequestOut]](url.listPullRequests(repo, head), modify(repo))
       .map(_.values)
+
+  override def closePullRequest(repo: Repo, id: Int): F[PullRequestOut] =
+    client.putWithBody[PullRequestOut, UpdateState](
+      url.pullRequest(repo, id),
+      UpdateState(PullRequestState.Closed),
+      modify(repo)
+    )
 }
diff --git a/modules/core/src/main/scala/org/scalasteward/core/bitbucketserver/http4s/Http4sBitbucketServerApiAlg.scala b/modules/core/src/main/scala/org/scalasteward/core/bitbucketserver/http4s/Http4sBitbucketServerApiAlg.scala
index 74044c34..df7f0062 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/bitbucketserver/http4s/Http4sBitbucketServerApiAlg.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/bitbucketserver/http4s/Http4sBitbucketServerApiAlg.scala
@@ -95,6 +95,12 @@ class Http4sBitbucketServerApiAlg[F[_]](
 
   def ni(name: String): Nothing = throw new NotImplementedError(name)
 
+  override def closePullRequest(repo: Repo, id: Int): F[PullRequestOut] =
+    client.putWithBody[PullRequestOut, UpdateState](
+      url.pullRequest(repo, id),
+      UpdateState(PullRequestState.Closed),
+      modify(repo)
+    )
 }
 
 final class StashUrls(base: Uri) {
@@ -106,6 +112,8 @@ final class StashUrls(base: Uri) {
 
   def pullRequests(r: Repo): Uri = repo(r) / "pull-requests"
 
+  def pullRequest(r: Repo, id: Int): Uri = repo(r) / "pull-requests" / id.toString
+
   def reviewers(repo: Repo): Uri =
     reviewerApi / "projects" / repo.owner / "repos" / repo.repo / "conditions"
 
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 7e3ffa67..2a772899 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
@@ -53,6 +53,13 @@ sealed trait Update extends Product with Serializable {
     }
     s"$groupId:$artifacts : $versions"
   }
+
+  def withNewerVersions(versions: Nel[String]): Update = this match {
+    case s @ Single(_, _, _) =>
+      s.copy(newerVersions = versions)
+    case g @ Group(_, _) =>
+      g.copy(newerVersions = versions)
+  }
 }
 
 object Update {
diff --git a/modules/core/src/main/scala/org/scalasteward/core/github/Url.scala b/modules/core/src/main/scala/org/scalasteward/core/github/Url.scala
index 4390649a..286742b0 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/github/Url.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/github/Url.scala
@@ -36,6 +36,9 @@ class Url(apiHost: Uri) {
   def pulls(repo: Repo): Uri =
     repos(repo) / "pulls"
 
+  def pull(repo: Repo, id: Int): Uri =
+    repos(repo) / "pulls" / id.toString
+
   def repos(repo: Repo): Uri =
     apiHost / "repos" / repo.owner / repo.repo
 }
diff --git a/modules/core/src/main/scala/org/scalasteward/core/github/http4s/Http4sGitHubApiAlg.scala b/modules/core/src/main/scala/org/scalasteward/core/github/http4s/Http4sGitHubApiAlg.scala
index 7202f3e1..06f928b2 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/github/http4s/Http4sGitHubApiAlg.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/github/http4s/Http4sGitHubApiAlg.scala
@@ -45,4 +45,11 @@ final class Http4sGitHubApiAlg[F[_]](
 
   override def listPullRequests(repo: Repo, head: String, base: Branch): F[List[PullRequestOut]] =
     client.get(url.listPullRequests(repo, head, base), modify(repo))
+
+  override def closePullRequest(repo: Repo, id: Int): F[PullRequestOut] =
+    client.patchWithBody[PullRequestOut, UpdateState](
+      url.pull(repo, id),
+      UpdateState(PullRequestState.Closed),
+      modify(repo)
+    )
 }
diff --git a/modules/core/src/main/scala/org/scalasteward/core/gitlab/http4s/Http4sGitlabApiAlg.scala b/modules/core/src/main/scala/org/scalasteward/core/gitlab/http4s/Http4sGitlabApiAlg.scala
index f4b97adc..0559c63c 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/gitlab/http4s/Http4sGitlabApiAlg.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/gitlab/http4s/Http4sGitlabApiAlg.scala
@@ -20,6 +20,7 @@ import cats.syntax.all._
 import io.chrisdavenport.log4cats.Logger
 import io.circe._
 import io.circe.generic.semiauto._
+import io.circe.syntax._
 import org.http4s.{Request, Status, Uri}
 import org.scalasteward.core.git.{Branch, Sha1}
 import org.scalasteward.core.gitlab._
@@ -102,6 +103,14 @@ private[http4s] object GitlabJsonCodec {
 
   implicit val projectIdDecoder: Decoder[ProjectId] = deriveDecoder
   implicit val mergeRequestPayloadEncoder: Encoder[MergeRequestPayload] = deriveEncoder
+  implicit val updateStateEncoder: Encoder[UpdateState] = Encoder.instance { newState =>
+    val encoded = newState.state match {
+      case PullRequestState.Open   => "open"
+      case PullRequestState.Closed => "close"
+    }
+    Json.obj("state_event" -> encoded.asJson)
+  }
+
   implicit val pullRequestOutDecoder: Decoder[PullRequestOut] =
     mergeRequestOutDecoder.map(_.pullRequestOut)
   implicit val commitOutDecoder: Decoder[CommitOut] = deriveDecoder[CommitId].map(_.commitOut)
@@ -191,6 +200,15 @@ class Http4sGitLabApiAlg[F[_]](
     updatedMergeRequest.map(_.pullRequestOut)
   }
 
+  def closePullRequest(repo: Repo, id: Int): F[PullRequestOut] =
+    client
+      .putWithBody[MergeRequestOut, UpdateState](
+        url.existingMergeRequest(repo, id),
+        UpdateState(PullRequestState.Closed),
+        modify(repo)
+      )
+      .map(_.pullRequestOut)
+
   def getBranch(repo: Repo, branch: Branch): F[BranchOut] =
     client.get(url.getBranch(repo, branch), modify(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 124f9ed2..ac068a4d 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
@@ -21,6 +21,7 @@ import cats.implicits._
 import eu.timepit.refined.types.numeric.PosInt
 import fs2.Stream
 import io.chrisdavenport.log4cats.Logger
+import org.http4s.Uri
 import org.scalasteward.core.application.Config
 import org.scalasteward.core.coursier.CoursierAlg
 import org.scalasteward.core.data.ProcessResult.{Ignored, Updated}
@@ -31,10 +32,12 @@ import org.scalasteward.core.repocache.RepoCacheRepository
 import org.scalasteward.core.repoconfig.{PullRequestUpdateStrategy, RepoConfigAlg}
 import org.scalasteward.core.scalafix.MigrationAlg
 import org.scalasteward.core.util.{BracketThrow, HttpExistenceClient}
-import org.scalasteward.core.vcs.data.{NewPullRequestData, Repo, RepoOut}
+import org.scalasteward.core.vcs.data.{NewPullRequestData, PullRequestState, Repo, RepoOut}
 import org.scalasteward.core.vcs.{VCSApiAlg, VCSExtraAlg, VCSRepoAlg}
 import org.scalasteward.core.{git, util, vcs}
 
+import scala.util.control.NonFatal
+
 final class NurtureAlg[F[_]](implicit
     config: Config,
     editAlg: EditAlg[F],
@@ -81,10 +84,11 @@ final class NurtureAlg[F[_]](implicit
       baseSha1 <- gitAlg.latestSha1(repo, baseBranch)
       _ <- NurtureAlg.processUpdates(
         sorted,
-        update =>
-          processUpdate(
+        update => {
+          val updateData =
             UpdateData(repo, fork, repoConfig, update, baseBranch, baseSha1, git.branchFor(update))
-          ),
+          processUpdate(updateData) <* closeObsoletePullRequests(updateData)
+        },
         repoConfig.updates.limit
       )
     } yield ()
@@ -113,6 +117,38 @@ final class NurtureAlg[F[_]](implicit
       }
     } yield result
 
+  def closeObsoletePullRequests(data: UpdateData): F[Unit] = {
+
+    def closePR(repo: Repo, id: Int): F[Unit] =
+      vcsApiAlg.closePullRequest(repo, id).as(())
+
+    def close(id: Int, repo: Repo, updateData: Update, url: Uri): F[Unit] =
+      for {
+        _ <- closePR(repo, id)
+        _ <- logger.info(
+          s"Closed a PR @ ${url.renderString} for ${updateData.show}"
+        )
+        _ <- pullRequestRepository.changeState(repo, url, PullRequestState.Closed)
+      } yield ()
+
+    for {
+      _ <- logger.info(s"Looking to close obsolete PRs for ${data.update.name}")
+      prsToClose <- pullRequestRepository.getObsoleteOpenPullRequests(data.repo, data.update)
+      dataPerId = prsToClose.flatMap { case (url, data) =>
+        vcsExtraAlg
+          .extractPRIdFromUrls(config.vcsType, url)
+          .tupleRight((data, url))
+          .toList
+      }
+      _ <- dataPerId.traverse { case (id, (pullRequestData, url)) =>
+        close(id, data.repo, pullRequestData, url).handleErrorWith { case NonFatal(ex) =>
+          logger.warn(ex)(s"Failed to close obsolete PR #$id for ${data.updateBranch.name}")
+        }
+      }
+
+    } yield ()
+  }
+
   def applyNewUpdate(data: UpdateData): F[ProcessResult] =
     (editAlg.applyUpdate(
       data.repo,
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 b36599b2..906f04d3 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
@@ -19,7 +19,7 @@ package org.scalasteward.core.nurture
 import cats.Monad
 import cats.implicits._
 import org.http4s.Uri
-import org.scalasteward.core.data.{CrossDependency, Update}
+import org.scalasteward.core.data.{CrossDependency, Update, Version}
 import org.scalasteward.core.git.Sha1
 import org.scalasteward.core.persistence.KeyValueStore
 import org.scalasteward.core.update.UpdateAlg
@@ -32,6 +32,17 @@ final class PullRequestRepository[F[_]](
     dateTimeAlg: DateTimeAlg[F],
     F: Monad[F]
 ) {
+  def changeState(repo: Repo, url: Uri, newState: PullRequestState): F[Unit] =
+    kvStore
+      .modifyF(repo) { maybePullRequests =>
+        val pullRequests = maybePullRequests.getOrElse(Map.empty)
+        pullRequests.get(url).traverse { found =>
+          val data = found.copy(state = newState)
+          pullRequests.updated(url, data).pure[F]
+        }
+      }
+      .void
+
   def createOrUpdate(
       repo: Repo,
       url: Uri,
@@ -55,6 +66,20 @@ final class PullRequestRepository[F[_]](
       }
       .void
 
+  def getObsoleteOpenPullRequests(
+      repo: Repo,
+      updateData: Update
+  ): F[List[(Uri, Update)]] =
+    kvStore.get(repo).map {
+      _.getOrElse(Map.empty).collect {
+        case (url, data)
+            if data.update.withNewerVersions(updateData.newerVersions) === updateData &&
+              Version(data.update.nextVersion) < Version(updateData.nextVersion) &&
+              data.state === PullRequestState.Open =>
+          url -> data.update
+      }.toList
+    }
+
   def findPullRequest(
       repo: Repo,
       crossDependency: CrossDependency,
diff --git a/modules/core/src/main/scala/org/scalasteward/core/util/HttpJsonClient.scala b/modules/core/src/main/scala/org/scalasteward/core/util/HttpJsonClient.scala
index fdf20b25..229a9742 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/util/HttpJsonClient.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/util/HttpJsonClient.scala
@@ -19,10 +19,11 @@ package org.scalasteward.core.util
 import cats.effect.Sync
 import cats.syntax.all._
 import io.circe.{Decoder, Encoder}
-import org.http4s.Method.{GET, POST, PUT}
+import org.http4s.Method.{GET, PATCH, POST, PUT}
 import org.http4s.circe.{jsonEncoderOf, jsonOf}
 import org.http4s.client.Client
 import org.http4s._
+
 import scala.util.control.NoStackTrace
 
 final class HttpJsonClient[F[_]: Sync](implicit
@@ -39,9 +40,18 @@ final class HttpJsonClient[F[_]: Sync](implicit
   def put[A: Decoder](uri: Uri, modify: ModReq): F[A] =
     request[A](PUT, uri, modify)
 
+  def patch[A: Decoder](uri: Uri, modify: ModReq): F[A] =
+    request[A](PATCH, uri, modify)
+
   def postWithBody[A: Decoder, B: Encoder](uri: Uri, body: B, modify: ModReq): F[A] =
     post[A](uri, modify.compose(_.withEntity(body)(jsonEncoderOf[F, B])))
 
+  def putWithBody[A: Decoder, B: Encoder](uri: Uri, body: B, modify: ModReq): F[A] =
+    put[A](uri, modify.compose(_.withEntity(body)(jsonEncoderOf[F, B])))
+
+  def patchWithBody[A: Decoder, B: Encoder](uri: Uri, body: B, modify: ModReq): F[A] =
+    patch[A](uri, modify.compose(_.withEntity(body)(jsonEncoderOf[F, B])))
+
   private def request[A: Decoder](method: Method, uri: Uri, modify: ModReq): F[A] =
     client.expectOr[A](modify(Request[F](method, uri)))(resp =>
       toUnexpectedResponse(uri, method, resp)
diff --git a/modules/core/src/main/scala/org/scalasteward/core/vcs/VCSApiAlg.scala b/modules/core/src/main/scala/org/scalasteward/core/vcs/VCSApiAlg.scala
index bf355ef9..7dd78fb9 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/vcs/VCSApiAlg.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/vcs/VCSApiAlg.scala
@@ -26,6 +26,8 @@ trait VCSApiAlg[F[_]] {
 
   def createPullRequest(repo: Repo, data: NewPullRequestData): F[PullRequestOut]
 
+  def closePullRequest(repo: Repo, id: Int): F[PullRequestOut]
+
   def getBranch(repo: Repo, branch: Branch): F[BranchOut]
 
   def getRepo(repo: Repo): F[RepoOut]
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 b11bfe85..84bae0ae 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
@@ -19,13 +19,14 @@ package org.scalasteward.core.vcs
 import cats.Monad
 import cats.syntax.all._
 import org.http4s.Uri
-import org.scalasteward.core.application.Config
+import org.scalasteward.core.application.{Config, SupportedVCS}
 import org.scalasteward.core.data.{ReleaseRelatedUrl, Update}
 import org.scalasteward.core.util.HttpExistenceClient
 import org.scalasteward.core.vcs
 
 trait VCSExtraAlg[F[_]] {
   def getReleaseRelatedUrls(repoUrl: Uri, update: Update): F[List[ReleaseRelatedUrl]]
+  def extractPRIdFromUrls(vsc: SupportedVCS, uri: Uri): Option[Int]
 }
 
 object VCSExtraAlg {
@@ -39,5 +40,24 @@ object VCSExtraAlg {
         vcs
           .possibleReleaseRelatedUrls(config.vcsType, config.vcsApiHost, repoUrl, update)
           .filterA(releaseRelatedUrl => existenceClient.exists(releaseRelatedUrl.url))
+
+      override def extractPRIdFromUrls(vcs: SupportedVCS, uri: Uri): Option[Int] = {
+        def extractIntAfter(value: String, matchPart: String): Option[Int] = {
+          val regex = raw".*/$matchPart/(\d+)".r
+          value match {
+            case regex(id) => scala.util.Try(id.toInt).toOption
+            case _         => None
+          }
+        }
+
+        vcs match {
+          case SupportedVCS.GitHub =>
+            extractIntAfter(uri.path, "pull")
+          case SupportedVCS.Bitbucket | SupportedVCS.BitbucketServer =>
+            extractIntAfter(uri.path, "pullrequests")
+          case SupportedVCS.Gitlab =>
+            extractIntAfter(uri.path, "merge_requests")
+        }
+      }
     }
 }
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 de6375c8..8b991a76 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
@@ -28,6 +28,13 @@ import org.scalasteward.core.repoconfig.RepoConfigAlg
 import org.scalasteward.core.scalafix.Migration
 import org.scalasteward.core.util.{Details, Nel}
 
+final case class UpdateState(
+    state: PullRequestState
+)
+object UpdateState {
+  implicit val updateStateEncoder: Encoder[UpdateState] = deriveEncoder
+}
+
 final case class NewPullRequestData(
     title: String,
     body: String,


Test patch:
diff --git a/modules/core/src/test/scala/org/scalasteward/core/bitbucket/http4s/Http4sBitbucketApiAlgTest.scala b/modules/core/src/test/scala/org/scalasteward/core/bitbucket/http4s/Http4sBitbucketApiAlgTest.scala
index 48cdc913..8ce2c449 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/bitbucket/http4s/Http4sBitbucketApiAlgTest.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/bitbucket/http4s/Http4sBitbucketApiAlgTest.scala
@@ -123,6 +123,18 @@ class Http4sBitbucketApiAlgTest extends AnyFunSuite with Matchers {
           ]
       }"""
       )
+    case PUT -> Root / "repositories" / "fthomas" / "base.g8" / "pullrequests" / IntVar(_) =>
+      Ok(
+        json"""{
+            "title": "scala-steward-pr",
+            "state": "DECLINED",
+            "links": {
+                "self": {
+                    "href": "https://api.bitbucket.org/2.0/repositories/fthomas/base.g8/pullrequests/2"
+                }
+            }
+        }"""
+      )
   }
 
   implicit val client: Client[IO] = Client.fromHttpApp(routes.orNotFound)
@@ -199,4 +211,9 @@ class Http4sBitbucketApiAlgTest extends AnyFunSuite with Matchers {
     val prs = bitbucketApiAlg.listPullRequests(repo, "master", master).unsafeRunSync()
     (prs should contain).only(pullRequest)
   }
+
+  test("closePullRequest") {
+    val pr = bitbucketApiAlg.closePullRequest(repo, 1).unsafeRunSync()
+    pr shouldBe pr.copy(state = PullRequestState.Closed)
+  }
 }
diff --git a/modules/core/src/test/scala/org/scalasteward/core/github/http4s/Http4sGitHubApiAlgTest.scala b/modules/core/src/test/scala/org/scalasteward/core/github/http4s/Http4sGitHubApiAlgTest.scala
index 5548dafa..cb017127 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/github/http4s/Http4sGitHubApiAlgTest.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/github/http4s/Http4sGitHubApiAlgTest.scala
@@ -36,6 +36,24 @@ class Http4sGitHubApiAlgTest extends AnyFunSuite with Matchers {
           } """
         )
 
+      case GET -> Root / "repos" / "fthomas" / "base.g8" / "pulls" =>
+        Ok(
+          json"""[{
+            "html_url": "https://github.com/octocat/Hello-World/pull/1347",
+            "state": "open",
+            "title": "new-feature"
+          }]"""
+        )
+
+      case PATCH -> Root / "repos" / "fthomas" / "base.g8" / "pulls" / IntVar(_) =>
+        Ok(
+          json"""{
+            "html_url": "https://github.com/octocat/Hello-World/pull/1347",
+            "state": "closed",
+            "title": "new-feature"
+          }"""
+        )
+
       case POST -> Root / "repos" / "fthomas" / "base.g8" / "forks" =>
         Ok(
           json""" {
@@ -107,4 +125,9 @@ class Http4sGitHubApiAlgTest extends AnyFunSuite with Matchers {
     repoOut shouldBe parent
     branchOut shouldBe defaultBranch
   }
+
+  test("closePullRequest") {
+    val prOut = gitHubApiAlg.closePullRequest(repo, 1347).unsafeRunSync()
+    prOut.state shouldBe PullRequestState.Closed
+  }
 }
diff --git a/modules/core/src/test/scala/org/scalasteward/core/gitlab/http4s/Http4sGitlabApiAlgTest.scala b/modules/core/src/test/scala/org/scalasteward/core/gitlab/http4s/Http4sGitlabApiAlgTest.scala
index d18e6dad..774c05ca 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/gitlab/http4s/Http4sGitlabApiAlgTest.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/gitlab/http4s/Http4sGitlabApiAlgTest.scala
@@ -50,6 +50,13 @@ class Http4sGitlabApiAlgTest extends AnyFunSuite with Matchers {
           )
         )
 
+      case PUT -> Root / "projects" / "foo/bar" / "merge_requests" / IntVar(_) =>
+        Ok(
+          getMr.deepMerge(
+            json""" { "state": "closed" } """
+          )
+        )
+
       case PUT -> Root / "projects" / "foo/bar" / "merge_requests" / "150" / "merge"
           :? MergeWhenPipelineSucceedsMatcher(_) =>
         Ok(
@@ -125,6 +132,19 @@ class Http4sGitlabApiAlgTest extends AnyFunSuite with Matchers {
     decode[ProjectId](getRepo.spaces2) shouldBe Right(ProjectId(12414871L))
   }
 
+  test("closePullRequest") {
+    val prOut =
+      gitlabApiAlg
+        .closePullRequest(Repo("foo", "bar"), 7115)
+        .unsafeRunSync()
+
+    prOut shouldBe PullRequestOut(
+      uri"https://gitlab.com/foo/bar/merge_requests/7115",
+      PullRequestState.Closed,
+      "title"
+    )
+  }
+
   test("createPullRequest -- auto merge") {
     val gitlabApiAlgNoFork =
       new Http4sGitLabApiAlg[IO](
diff --git a/modules/core/src/test/scala/org/scalasteward/core/mock/MockContext.scala b/modules/core/src/test/scala/org/scalasteward/core/mock/MockContext.scala
index 905674d7..54f9b7fc 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/mock/MockContext.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/mock/MockContext.scala
@@ -84,6 +84,6 @@ object MockContext {
   implicit val editAlg: EditAlg[MockEff] = new EditAlg[MockEff]
   implicit val repoConfigAlg: RepoConfigAlg[MockEff] = new RepoConfigAlg[MockEff]
   implicit val pullRequestRepository: PullRequestRepository[MockEff] =
-    new PullRequestRepository[MockEff](new JsonKeyValueStore("pull_requests", "1"))
+    new PullRequestRepository[MockEff](new JsonKeyValueStore("pull_requests", "2"))
   implicit val pruningAlg: PruningAlg[MockEff] = new PruningAlg[MockEff]
 }
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 2597e2a3..639ea21c 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
@@ -1,5 +1,6 @@
 package org.scalasteward.core.nurture
 
+import org.http4s.Uri
 import org.http4s.syntax.literals._
 import org.scalasteward.core.TestSyntax._
 import org.scalasteward.core.data.Update
@@ -13,30 +14,161 @@ import org.scalatest.funsuite.AnyFunSuite
 import org.scalatest.matchers.should.Matchers
 
 class PullRequestRepositoryTest extends AnyFunSuite with Matchers {
-  test("createOrUpdate >> findPullRequest >> lastPullRequestCreatedAt") {
+  private def checkCommands(state: MockState, commands: Vector[List[String]]) =
+    state.copy(files = Map.empty) shouldBe MockState.empty.copy(commands = commands)
+
+  withStoreData { (repo, url, sha1) =>
+    test("createOrUpdate >> findPullRequest >> lastPullRequestCreatedAt") {
+      val update = TestData.Updates.PortableScala
+
+      val p = for {
+        _ <- pullRequestRepository.createOrUpdate(repo, url, sha1, update, PullRequestState.Open)
+        result <- pullRequestRepository.findPullRequest(repo, update.crossDependency, "1.0.0")
+        createdAt <- pullRequestRepository.lastPullRequestCreatedAt(repo)
+      } yield (result, createdAt)
+      val (state, (result, createdAt)) = p.run(MockState.empty).unsafeRunSync()
+
+      val store = config.workspace / "store/pull_requests/v2/typelevel/cats/pull_requests.json"
+      result shouldBe Some((url, sha1, PullRequestState.Open))
+      createdAt.isDefined shouldBe true
+
+      checkCommands(
+        state,
+        Vector(
+          List("read", store.toString),
+          List("write", store.toString),
+          List("read", store.toString),
+          List("read", store.toString)
+        )
+      )
+    }
+
+    test("getObsoleteOpenPullRequests for single update") {
+      val update = TestData.Updates.PortableScala
+      val nextUpdate = TestData.Updates.PortableScala.copy(newerVersions = Nel.of("1.0.1"))
+
+      val p = for {
+        emptyResult <- pullRequestRepository.getObsoleteOpenPullRequests(
+          repo,
+          nextUpdate
+        )
+        _ <- pullRequestRepository.createOrUpdate(repo, url, sha1, update, PullRequestState.Open)
+        result <- pullRequestRepository.getObsoleteOpenPullRequests(
+          repo,
+          nextUpdate
+        )
+        _ <- pullRequestRepository.changeState(repo, url, PullRequestState.Closed)
+        closedResult <- pullRequestRepository.getObsoleteOpenPullRequests(
+          repo,
+          nextUpdate
+        )
+      } yield (emptyResult, result, closedResult)
+      val (state, (emptyResult, result, closedResult)) = p.run(MockState.empty).unsafeRunSync()
+      val store = config.workspace / "store/pull_requests/v2/typelevel/cats/pull_requests.json"
+      emptyResult shouldBe List.empty
+      closedResult shouldBe List.empty
+      result shouldBe List(
+        url -> TestData.Updates.PortableScala
+      )
+
+      checkCommands(
+        state,
+        Vector(
+          List("read", store.toString),
+          List("read", store.toString),
+          List("write", store.toString),
+          List("read", store.toString),
+          List("read", store.toString),
+          List("write", store.toString),
+          List("read", store.toString)
+        )
+      )
+    }
+
+    test("getObsoleteOpenPullRequests for the same single update") {
+      val update = TestData.Updates.PortableScala
+
+      val p = for {
+        emptyResult <- pullRequestRepository.getObsoleteOpenPullRequests(
+          repo,
+          update
+        )
+        _ <- pullRequestRepository.createOrUpdate(repo, url, sha1, update, PullRequestState.Open)
+        result <- pullRequestRepository.getObsoleteOpenPullRequests(
+          repo,
+          update
+        )
+      } yield (emptyResult, result)
+      val (state, (emptyResult, result)) = p.run(MockState.empty).unsafeRunSync()
+      val store = config.workspace / "store/pull_requests/v2/typelevel/cats/pull_requests.json"
+      emptyResult shouldBe List.empty
+      result shouldBe List.empty
+
+      checkCommands(
+        state,
+        Vector(
+          List("read", store.toString),
+          List("read", store.toString),
+          List("write", store.toString),
+          List("read", store.toString)
+        )
+      )
+    }
+
+    test("getObsoleteOpenPullRequests for the another single update and ignore closed") {
+      val updateInStore = TestData.Updates.PortableScala
+      val newUpdate = TestData.Updates.CatsCore
+
+      val p = for {
+        emptyResult <- pullRequestRepository.getObsoleteOpenPullRequests(
+          repo,
+          updateInStore
+        )
+        _ <- pullRequestRepository.createOrUpdate(
+          repo,
+          url,
+          sha1,
+          updateInStore,
+          PullRequestState.Open
+        )
+        result <- pullRequestRepository.getObsoleteOpenPullRequests(
+          repo,
+          newUpdate
+        )
+      } yield (emptyResult, result)
+      val (state, (emptyResult, result)) = p.run(MockState.empty).unsafeRunSync()
+      val store = config.workspace / "store/pull_requests/v2/typelevel/cats/pull_requests.json"
+      emptyResult shouldBe List.empty
+      result shouldBe List.empty
+
+      checkCommands(
+        state,
+        Vector(
+          List("read", store.toString),
+          List("read", store.toString),
+          List("write", store.toString),
+          List("read", store.toString)
+        )
+      )
+    }
+  }
+
+  private def withStoreData(f: (Repo, Uri, Sha1) => Unit): Unit = {
     val repo = Repo("typelevel", "cats")
     val url = uri"https://github.com/typelevel/cats/pull/3291"
     val sha1 = Sha1(HexString("a2ced5793c2832ada8c14ba5c77e51c4bc9656a8"))
-    val update =
+
+    f(repo, url, sha1)
+  }
+}
+
+private[this] object TestData {
+  object Updates {
+    val PortableScala: Update.Single =
       Update.Single("org.portable-scala" % "sbt-scalajs-crossproject" % "0.6.1", Nel.of("1.0.0"))
 
-    val p = for {
-      _ <- pullRequestRepository.createOrUpdate(repo, url, sha1, update, PullRequestState.Open)
-      result <- pullRequestRepository.findPullRequest(repo, update.crossDependency, "1.0.0")
-      createdAt <- pullRequestRepository.lastPullRequestCreatedAt(repo)
-    } yield (result, createdAt)
-    val (state, (result, createdAt)) = p.run(MockState.empty).unsafeRunSync()
-
-    val store = config.workspace / "store/pull_requests/v1/typelevel/cats/pull_requests.json"
-    result shouldBe Some((url, sha1, PullRequestState.Open))
-    createdAt.isDefined shouldBe true
-    state.copy(files = Map.empty) shouldBe MockState.empty.copy(
-      commands = Vector(
-        List("read", store.toString),
-        List("write", store.toString),
-        List("read", store.toString),
-        List("read", store.toString)
-      )
-    )
+    val CatsCore: Update.Single =
+      Update.Single("org.typelevel" % "cats-core" % "1.0.0", Nel.of("1.0.1"))
+
   }
 }
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 2232f543..3d4fddd6 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
@@ -24,7 +24,7 @@ class PruningAlgTest extends AnyFunSuite with Matchers {
           |  }
           |}""".stripMargin
     val pullRequestsFile =
-      config.workspace / "store/pull_requests/v1/fthomas/scalafix-test/pull_requests.json"
+      config.workspace / "store/pull_requests/v2/fthomas/scalafix-test/pull_requests.json"
     val pullRequestsContent =
       s"""|{
           |  "https://github.com/fthomas/scalafix-test/pull/27" : {
@@ -117,7 +117,7 @@ class PruningAlgTest extends AnyFunSuite with Matchers {
           |  }
           |}""".stripMargin
     val pullRequestsFile =
-      config.workspace / "store/pull_requests/v1/fthomas/scalafix-test/pull_requests.json"
+      config.workspace / "store/pull_requests/v2/fthomas/scalafix-test/pull_requests.json"
     val pullRequestsContent =
       s"""|{
           |  "https://github.com/fthomas/scalafix-test/pull/27" : {
@@ -235,7 +235,7 @@ class PruningAlgTest extends AnyFunSuite with Matchers {
           |  }
           |}""".stripMargin
     val pullRequestsFile =
-      config.workspace / "store/pull_requests/v1/fthomas/scalafix-test/pull_requests.json"
+      config.workspace / "store/pull_requests/v2/fthomas/scalafix-test/pull_requests.json"
     val pullRequestsContent =
       s"""|{
           |  "https://github.com/fthomas/scalafix-test/pull/27" : {
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 6112f4ae..e14862c9 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
@@ -70,4 +70,30 @@ class VCSExtraAlgTest extends AnyFunSuite with Matchers {
       .getReleaseRelatedUrls(uri"https://github.on-prem.com/foo/buz", updateFoo)
       .unsafeRunSync() shouldBe List.empty
   }
+
+  val validExtractPRTestCases = List(
+    SupportedVCS.Bitbucket -> uri"https://api.bitbucket.org/2.0/repositories/fthomas/base.g8/pullrequests/13",
+    SupportedVCS.BitbucketServer -> uri"https://api.bitbucket.org/2.0/repositories/fthomas/base.g8/pullrequests/13",
+    SupportedVCS.GitHub -> uri"https://github.com/scala-steward-org/scala-steward/pull/13",
+    SupportedVCS.Gitlab -> uri"https://gitlab.com/inkscape/inkscape/-/merge_requests/13"
+  )
+  validExtractPRTestCases.foreach { case (vcs, uri) =>
+    test(s"valid - extractPRIdFromUrls for ${vcs.asString}") {
+      val vcsExtraAlg = VCSExtraAlg.create[IO]
+      vcsExtraAlg.extractPRIdFromUrls(vcs, uri) shouldBe Some(13)
+    }
+  }
+
+  val invalidExtractPRTestCases = List(
+    SupportedVCS.Bitbucket -> uri"https://api.bitbucket.org/2.0/repositories/fthomas/base.g8/pullrequests/",
+    SupportedVCS.BitbucketServer -> uri"https://api.bitbucket.org/2.0/repositories/fthomas/base.g8/pullrequests/",
+    SupportedVCS.GitHub -> uri"https://github.com/scala-steward-org/scala-steward/pull/",
+    SupportedVCS.Gitlab -> uri"https://gitlab.com/inkscape/inkscape/-/merge_requests/"
+  )
+  invalidExtractPRTestCases.foreach { case (vcs, uri) =>
+    test(s"invalid - extractPRIdFromUrls for ${vcs.asString}") {
+      val vcsExtraAlg = VCSExtraAlg.create[IO]
+      vcsExtraAlg.extractPRIdFromUrls(vcs, uri) shouldBe None
+    }
+  }
 }