Repository: scala-steward-org/scala-steward
Pull request #1840 (2020-12-26 11:32:02)

KVStore: delete JSON file when setting no value
Subsequent reads will not need to read a file that only contains `null`.


Patch:
diff --git a/modules/core/src/main/scala/org/scalasteward/core/data/ProcessResult.scala b/modules/core/src/main/scala/org/scalasteward/core/data/ProcessResult.scala
index 956f85b7..2e06be6b 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/data/ProcessResult.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/data/ProcessResult.scala
@@ -16,9 +16,12 @@
 
 package org.scalasteward.core.data
 
+import org.scalasteward.core.vcs.data.PullRequestNumber
+
 sealed trait ProcessResult extends Product with Serializable
 
 object ProcessResult {
   case object Ignored extends ProcessResult
   case object Updated extends ProcessResult
+  case class Created(prNumber: PullRequestNumber) extends ProcessResult
 }
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 86d03a2e..f3519c91 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
@@ -25,7 +25,7 @@ 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}
+import org.scalasteward.core.data.ProcessResult.{Created, Ignored, Updated}
 import org.scalasteward.core.data.{ProcessResult, Scope, Update}
 import org.scalasteward.core.edit.EditAlg
 import org.scalasteward.core.git.{Branch, Commit, GitAlg}
@@ -84,8 +84,9 @@ final class NurtureAlg[F[_]](config: Config)(implicit
           val updateData =
             UpdateData(repo, fork, repoConfig, update, baseBranch, baseSha1, git.branchFor(update))
           processUpdate(updateData).flatMap {
-            case result @ Updated => closeObsoletePullRequests(updateData).as(result)
-            case result @ Ignored => F.pure(result)
+            case result @ Created(newPrNumber) =>
+              closeObsoletePullRequests(updateData, newPrNumber).as[ProcessResult](result)
+            case result @ _ => F.pure(result)
           }
         },
         repoConfig.updates.limit
@@ -117,11 +118,16 @@ final class NurtureAlg[F[_]](config: Config)(implicit
       }
     } yield result
 
-  def closeObsoletePullRequests(data: UpdateData): F[Unit] = {
+  def closeObsoletePullRequests(data: UpdateData, newPrNumber: PullRequestNumber): F[Unit] = {
     def close(number: PullRequestNumber, repo: Repo, update: Update, url: Uri): F[Unit] =
       for {
+        _ <- vcsApiAlg.commentPullRequest(
+          repo,
+          number,
+          s"Superseded by ${vcsApiAlg.referencePullRequest(newPrNumber)}."
+        )
         _ <- vcsApiAlg.closePullRequest(repo, number)
-        _ <- logger.info(s"Closed a PR @ ${url.renderString} for ${update.show}")
+        _ <- logger.info(s"Closed a PR @ ${url.renderString} for ${update.show} $newPrNumber")
         _ <- pullRequestRepository.changeState(repo, url, PullRequestState.Closed)
       } yield ()
 
@@ -144,7 +150,7 @@ final class NurtureAlg[F[_]](config: Config)(implicit
         editCommits <- editAlg.applyUpdate(data.repo, data.repoConfig, data.update)
         result <-
           if (editCommits.isEmpty) logger.warn("No commits created").as(Ignored)
-          else pushCommits(data, editCommits) >> createPullRequest(data).as(Updated)
+          else pushCommits(data, editCommits) >> createPullRequest(data)
       } yield result
     }
 
@@ -156,7 +162,7 @@ final class NurtureAlg[F[_]](config: Config)(implicit
         _ <- gitAlg.push(data.repo, data.updateBranch)
       } yield Updated
 
-  def createPullRequest(data: UpdateData): F[Unit] =
+  def createPullRequest(data: UpdateData): F[ProcessResult] =
     for {
       _ <- logger.info(s"Create PR ${data.updateBranch.name}")
       dependenciesWithNextVersion =
@@ -190,7 +196,7 @@ final class NurtureAlg[F[_]](config: Config)(implicit
         pr.number
       )
       _ <- logger.info(s"Created PR ${pr.html_url}")
-    } yield ()
+    } yield Created(pr.number)
 
   def updatePullRequest(data: UpdateData): F[ProcessResult] =
     if (data.repoConfig.updatePullRequestsOrDefault =!= PullRequestUpdateStrategy.Never)
@@ -248,8 +254,9 @@ object NurtureAlg {
           .emits(updates)
           .evalMap(updateF)
           .through(util.takeUntil(0, limit.value) {
-            case Ignored => 0
-            case Updated => 1
+            case Ignored    => 0
+            case Updated    => 1
+            case Created(_) => 1
           })
           .compile
           .drain
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 483cadfe..011a8db8 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
@@ -52,7 +52,12 @@ final class JsonKeyValueStore[F[_], K, V](
     }
 
   override def set(key: K, value: Option[V]): F[Unit] =
-    jsonFile(key).flatMap(fileAlg.writeFile(_, value.asJson.toString))
+    jsonFile(key).flatMap { file =>
+      value match {
+        case Some(v) => fileAlg.writeFile(file, v.asJson.toString)
+        case None    => fileAlg.deleteForce(file)
+      }
+    }
 
   private def jsonFile(key: K): F[File] = {
     val keyPath = maybePrefix.fold("")(_ + "/") + keyEncoder(key)
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 da262667..a3572514 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
@@ -34,6 +34,11 @@ trait VCSApiAlg[F[_]] {
 
   def listPullRequests(repo: Repo, head: String, base: Branch): F[List[PullRequestOut]]
 
+  def referencePullRequest(number: PullRequestNumber): String =
+    s"#${number.value}"
+
+  def commentPullRequest(repo: Repo, number: PullRequestNumber, comment: String): F[Comment]
+
   final def createForkOrGetRepo(repo: Repo, doNotFork: Boolean): F[RepoOut] =
     if (doNotFork) getRepo(repo) else createFork(repo)
 
diff --git a/modules/core/src/main/scala/org/scalasteward/core/vcs/bitbucket/BitbucketApiAlg.scala b/modules/core/src/main/scala/org/scalasteward/core/vcs/bitbucket/BitbucketApiAlg.scala
index 6a288615..2f0a6f2e 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/vcs/bitbucket/BitbucketApiAlg.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/vcs/bitbucket/BitbucketApiAlg.scala
@@ -25,6 +25,7 @@ import org.scalasteward.core.vcs.VCSApiAlg
 import org.scalasteward.core.vcs.bitbucket.json._
 import org.scalasteward.core.vcs.data._
 
+/** https://developer.atlassian.com/bitbucket/api/2/reference/ */
 class BitbucketApiAlg[F[_]](
     bitbucketApiHost: Uri,
     user: AuthenticatedUser,
@@ -94,4 +95,17 @@ class BitbucketApiAlg[F[_]](
       UpdateState(PullRequestState.Closed),
       modify(repo)
     )
+
+  override def commentPullRequest(
+      repo: Repo,
+      number: PullRequestNumber,
+      comment: String
+  ): F[Comment] =
+    client
+      .postWithBody[CreateComment, CreateComment](
+        url.comments(repo, number),
+        CreateComment(comment),
+        modify(repo)
+      )
+      .map((cc: CreateComment) => Comment(cc.content.raw))
 }
diff --git a/modules/core/src/main/scala/org/scalasteward/core/vcs/bitbucket/CreateComment.scala b/modules/core/src/main/scala/org/scalasteward/core/vcs/bitbucket/CreateComment.scala
new file mode 100644
index 00000000..6a6ef13a
--- /dev/null
+++ b/modules/core/src/main/scala/org/scalasteward/core/vcs/bitbucket/CreateComment.scala
@@ -0,0 +1,31 @@
+/*
+ * Copyright 2018-2020 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.vcs.bitbucket
+
+import io.circe.Codec
+import io.circe.generic.semiauto.deriveCodec
+
+final private[bitbucket] case class CreateComment(content: CommentContent)
+final private[bitbucket] case class CommentContent(raw: String)
+
+private[bitbucket] object CreateComment {
+  def apply(text: String): CreateComment =
+    CreateComment(CommentContent(text))
+
+  implicit val createCommentCodec: Codec[CreateComment] = deriveCodec
+  implicit val commentContentCodec: Codec[CommentContent] = deriveCodec
+}
diff --git a/modules/core/src/main/scala/org/scalasteward/core/vcs/bitbucket/Url.scala b/modules/core/src/main/scala/org/scalasteward/core/vcs/bitbucket/Url.scala
index 2b397794..152dafb9 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/vcs/bitbucket/Url.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/vcs/bitbucket/Url.scala
@@ -38,4 +38,7 @@ private[bitbucket] class Url(apiHost: Uri) {
 
   def repo(repo: Repo): Uri =
     apiHost / "repositories" / repo.owner / repo.repo
+
+  def comments(rep: Repo, number: PullRequestNumber): Uri =
+    pullRequest(rep, number) / "comments"
 }
diff --git a/modules/core/src/main/scala/org/scalasteward/core/vcs/bitbucketserver/BitbucketServerApiAlg.scala b/modules/core/src/main/scala/org/scalasteward/core/vcs/bitbucketserver/BitbucketServerApiAlg.scala
index 61c0061a..637a4f28 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/vcs/bitbucketserver/BitbucketServerApiAlg.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/vcs/bitbucketserver/BitbucketServerApiAlg.scala
@@ -100,6 +100,19 @@ class BitbucketServerApiAlg[F[_]](
       UpdateState(PullRequestState.Closed),
       modify(repo)
     )
+
+  override def commentPullRequest(
+      repo: Repo,
+      number: PullRequestNumber,
+      comment: String
+  ): F[Comment] =
+    client
+      .postWithBody[Json.Comment, Json.Comment](
+        url.comments(repo, number),
+        Json.Comment(comment),
+        modify(repo)
+      )
+      .map((c: Json.Comment) => Comment(c.text))
 }
 
 final class StashUrls(base: Uri) {
@@ -127,4 +140,7 @@ final class StashUrls(base: Uri) {
 
   def listBranch(r: Repo, branch: Branch): Uri =
     branches(r).withQueryParam("filterText", branch.name)
+
+  def comments(repo: Repo, number: PullRequestNumber): Uri =
+    pullRequest(repo, number) / "comments"
 }
diff --git a/modules/core/src/main/scala/org/scalasteward/core/vcs/bitbucketserver/Json.scala b/modules/core/src/main/scala/org/scalasteward/core/vcs/bitbucketserver/Json.scala
index 945619d1..15b552d8 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/vcs/bitbucketserver/Json.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/vcs/bitbucketserver/Json.scala
@@ -67,6 +67,8 @@ object Json {
 
   case class Branch(id: String, latestCommit: Sha1)
 
+  case class Comment(text: String)
+
   implicit def pageDecode[A: Decoder]: Decoder[Page[A]] = deriveDecoder
   implicit val repoDecode: Decoder[Repo] = deriveDecoder
   implicit val projectDecode: Decoder[Project] = deriveDecoder
@@ -79,6 +81,7 @@ object Json {
   implicit val conditionDecoder: Decoder[Condition] = deriveDecoder
   implicit val branchDecoder: Decoder[Branch] = deriveDecoder
   implicit val branchesDecoder: Decoder[Branches] = deriveDecoder
+  implicit val commentDecoder: Decoder[Comment] = deriveDecoder
 
   implicit val encodeNewPR: Encoder[NewPR] = deriveEncoder
   implicit val encodeRef: Encoder[Ref] = deriveEncoder
@@ -86,4 +89,5 @@ object Json {
   implicit val encodeProject: Encoder[Project] = deriveEncoder
   implicit val encodeReviewer: Encoder[Reviewer] = deriveEncoder
   implicit val encodeUser: Encoder[User] = deriveEncoder
+  implicit val encodeComment: Encoder[Comment] = deriveEncoder
 }
diff --git a/modules/core/src/main/scala/org/scalasteward/core/vcs/data/Comment.scala b/modules/core/src/main/scala/org/scalasteward/core/vcs/data/Comment.scala
new file mode 100644
index 00000000..e11853cc
--- /dev/null
+++ b/modules/core/src/main/scala/org/scalasteward/core/vcs/data/Comment.scala
@@ -0,0 +1,28 @@
+/*
+ * Copyright 2018-2020 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.vcs.data
+
+import io.circe.Codec
+import io.circe.generic.semiauto.deriveCodec
+
+final case class Comment(
+    body: String
+)
+
+object Comment {
+  implicit val commentCodec: Codec[Comment] = deriveCodec
+}
diff --git a/modules/core/src/main/scala/org/scalasteward/core/vcs/github/GitHubApiAlg.scala b/modules/core/src/main/scala/org/scalasteward/core/vcs/github/GitHubApiAlg.scala
index c54cf3cb..9896e4a3 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/vcs/github/GitHubApiAlg.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/vcs/github/GitHubApiAlg.scala
@@ -57,4 +57,14 @@ final class GitHubApiAlg[F[_]](
       UpdateState(PullRequestState.Closed),
       modify(repo)
     )
+
+  /** https://developer.github.com/v3/issues#create-an-issue-comment */
+  override def commentPullRequest(
+      repo: Repo,
+      number: PullRequestNumber,
+      comment: String
+  ): F[Comment] =
+    client
+      .postWithBody(url.comments(repo, number), Comment(comment), modify(repo))
+
 }
diff --git a/modules/core/src/main/scala/org/scalasteward/core/vcs/github/Url.scala b/modules/core/src/main/scala/org/scalasteward/core/vcs/github/Url.scala
index a3264889..e7a0a064 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/vcs/github/Url.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/vcs/github/Url.scala
@@ -41,4 +41,7 @@ class Url(apiHost: Uri) {
 
   def repos(repo: Repo): Uri =
     apiHost / "repos" / repo.owner / repo.repo
+
+  def comments(repo: Repo, number: PullRequestNumber): Uri =
+    repos(repo) / "issues" / number.toString / "comments"
 }
diff --git a/modules/core/src/main/scala/org/scalasteward/core/vcs/gitlab/GitLabApiAlg.scala b/modules/core/src/main/scala/org/scalasteward/core/vcs/gitlab/GitLabApiAlg.scala
index 5292f266..f649e115 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/vcs/gitlab/GitLabApiAlg.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/vcs/gitlab/GitLabApiAlg.scala
@@ -217,4 +217,16 @@ class GitLabApiAlg[F[_]](
 
   def getRepo(repo: Repo): F[RepoOut] =
     client.get(url.repos(repo), modify(repo))
+
+  override def referencePullRequest(number: PullRequestNumber): String =
+    s"!${number.value}"
+
+  // https://docs.gitlab.com/ee/api/notes.html#create-new-merge-request-note
+  override def commentPullRequest(
+      repo: Repo,
+      number: PullRequestNumber,
+      comment: String
+  ): F[Comment] =
+    client.postWithBody(url.comments(repo, number), Comment(comment), modify(repo))
+
 }
diff --git a/modules/core/src/main/scala/org/scalasteward/core/vcs/gitlab/Url.scala b/modules/core/src/main/scala/org/scalasteward/core/vcs/gitlab/Url.scala
index d56c6de7..1da17fdb 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/vcs/gitlab/Url.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/vcs/gitlab/Url.scala
@@ -49,4 +49,7 @@ class Url(apiHost: Uri) {
 
   def repos(repo: Repo): Uri =
     apiHost / "projects" / s"${repo.owner}/${repo.repo}"
+
+  def comments(repo: Repo, number: PullRequestNumber): Uri =
+    existingMergeRequest(repo, number) / "notes"
 }


Test patch:
diff --git a/modules/core/src/test/scala/org/scalasteward/core/persistence/JsonKeyValueStoreTest.scala b/modules/core/src/test/scala/org/scalasteward/core/persistence/JsonKeyValueStoreTest.scala
index cdee93a6..3303d48f 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/persistence/JsonKeyValueStoreTest.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/persistence/JsonKeyValueStoreTest.scala
@@ -15,11 +15,11 @@ class JsonKeyValueStoreTest extends FunSuite {
       v3 <- kvStore.get("k3")
     } yield (v1, v3)
     val (state, value) = p.run(MockState.empty).unsafeRunSync()
+    assertEquals(value, (Some("v1"), None))
 
     val k1File = config.workspace / "store" / "test" / "v0" / "k1" / "test.json"
     val k2File = config.workspace / "store" / "test" / "v0" / "k2" / "test.json"
     val k3File = config.workspace / "store" / "test" / "v0" / "k3" / "test.json"
-    assertEquals(value, (Some("v1") -> None))
     val expected = MockState.empty.copy(
       commands = Vector(
         List("write", k1File.toString),
@@ -35,12 +35,25 @@ class JsonKeyValueStoreTest extends FunSuite {
     assertEquals(state, expected)
   }
 
-  test("modifyF") {
+  test("modifyF, get, set") {
     val kvStore = new JsonKeyValueStore[MockEff, String, String]("test", "0")
     val p = for {
       _ <- kvStore.modifyF("k1")(_ => Option("v0").pure[MockEff])
       v1 <- kvStore.get("k1")
+      _ <- kvStore.set("k1", None)
     } yield v1
-    assertEquals(p.runA(MockState.empty).unsafeRunSync(), Some("v0"))
+    val (state, value) = p.run(MockState.empty).unsafeRunSync()
+    assertEquals(value, Some("v0"))
+
+    val k1File = config.workspace / "store" / "test" / "v0" / "k1" / "test.json"
+    val expected = MockState.empty.copy(
+      commands = Vector(
+        List("read", k1File.toString),
+        List("write", k1File.toString),
+        List("read", k1File.toString),
+        List("rm", "-rf", k1File.toString)
+      )
+    )
+    assertEquals(state, expected)
   }
 }
diff --git a/modules/core/src/test/scala/org/scalasteward/core/vcs/bitbucket/BitbucketApiAlgTest.scala b/modules/core/src/test/scala/org/scalasteward/core/vcs/bitbucket/BitbucketApiAlgTest.scala
index 99a85df5..e2813bdd 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/vcs/bitbucket/BitbucketApiAlgTest.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/vcs/bitbucket/BitbucketApiAlgTest.scala
@@ -137,6 +137,13 @@ class BitbucketApiAlgTest extends FunSuite {
             }
         }"""
       )
+    case POST -> Root / "repositories" / "fthomas" / "base.g8" / "pullrequests" /
+        IntVar(_) / "comments" =>
+      Created(json"""{
+                  "content": { 
+                      "raw": "Superseded by #1234"
+                  } 
+          }""")
   }
 
   implicit val client: Client[IO] = Client.fromHttpApp(routes.orNotFound)
@@ -219,4 +226,11 @@ class BitbucketApiAlgTest extends FunSuite {
     val pr = bitbucketApiAlg.closePullRequest(repo, PullRequestNumber(1)).unsafeRunSync()
     assertEquals(pr, pr.copy(state = PullRequestState.Closed))
   }
+
+  test("commentPullRequest") {
+    val comment = bitbucketApiAlg
+      .commentPullRequest(repo, PullRequestNumber(1), "Superseded by #1234")
+      .unsafeRunSync()
+    assertEquals(comment, Comment("Superseded by #1234"))
+  }
 }
diff --git a/modules/core/src/test/scala/org/scalasteward/core/vcs/bitbucketserver/BitbucketServerApiAlgTest.scala b/modules/core/src/test/scala/org/scalasteward/core/vcs/bitbucketserver/BitbucketServerApiAlgTest.scala
index 0bd29781..51166589 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/vcs/bitbucketserver/BitbucketServerApiAlgTest.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/vcs/bitbucketserver/BitbucketServerApiAlgTest.scala
@@ -23,6 +23,11 @@ class BitbucketServerApiAlgTest extends FunSuite {
            |    "links": { "self": [ { "href": "http://example.org" } ] }
            |  }
            |]}""".stripMargin)
+
+    case POST -> Root / "rest" / "api" / "1.0" / "projects" / repo.owner / "repos" / repo.repo / "pull-requests" / IntVar(
+          _
+        ) / "comments" =>
+      Created("""{ "text": "Superseded by #1234" }""")
   }
 
   implicit private val client: Client[IO] = Client.fromHttpApp(routes.orNotFound)
@@ -46,4 +51,11 @@ class BitbucketServerApiAlgTest extends FunSuite {
 
     assertEquals(pullRequests, expected)
   }
+
+  test("commentPullRequest") {
+    val comment = bitbucketServerApiAlg
+      .commentPullRequest(repo, PullRequestNumber(1347), "Superseded by #1234")
+      .unsafeRunSync()
+    assertEquals(comment, Comment("Superseded by #1234"))
+  }
 }
diff --git a/modules/core/src/test/scala/org/scalasteward/core/vcs/github/GitHubApiAlgTest.scala b/modules/core/src/test/scala/org/scalasteward/core/vcs/github/GitHubApiAlgTest.scala
index 4725265f..15b5e516 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/vcs/github/GitHubApiAlgTest.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/vcs/github/GitHubApiAlgTest.scala
@@ -71,6 +71,11 @@ class GitHubApiAlgTest extends FunSuite {
           } """
         )
 
+      case POST -> Root / "repos" / "fthomas" / "base.g8" / "issues" / IntVar(_) / "comments" =>
+        Created(json"""  {
+            "body": "Superseded by #1234"
+          } """)
+
       case req =>
         println(req.toString())
         NotFound()
@@ -131,4 +136,16 @@ class GitHubApiAlgTest extends FunSuite {
     val prOut = gitHubApiAlg.closePullRequest(repo, PullRequestNumber(1347)).unsafeRunSync()
     assertEquals(prOut.state, PullRequestState.Closed)
   }
+
+  test("commentPullRequest") {
+    val reference = gitHubApiAlg.referencePullRequest(PullRequestNumber(1347))
+    assertEquals(reference, "#1347")
+  }
+
+  test("commentPullRequest") {
+    val comment = gitHubApiAlg
+      .commentPullRequest(repo, PullRequestNumber(1347), "Superseded by #1234")
+      .unsafeRunSync()
+    assertEquals(comment, Comment("Superseded by #1234"))
+  }
 }
diff --git a/modules/core/src/test/scala/org/scalasteward/core/vcs/gitlab/GitLabApiAlgTest.scala b/modules/core/src/test/scala/org/scalasteward/core/vcs/gitlab/GitLabApiAlgTest.scala
index 571fae09..459187dc 100644
--- a/modules/core/src/test/scala/org/scalasteward/core/vcs/gitlab/GitLabApiAlgTest.scala
+++ b/modules/core/src/test/scala/org/scalasteward/core/vcs/gitlab/GitLabApiAlgTest.scala
@@ -64,6 +64,11 @@ class GitLabApiAlgTest extends FunSuite {
           )
         )
 
+      case POST -> Root / "projects" / "foo/bar" / "merge_requests" / "150" / "notes" =>
+        Ok(json"""  {
+            "body": "Superseded by #1234"
+          } """)
+
       case req =>
         println(req.toString())
         NotFound()
@@ -205,6 +210,18 @@ class GitLabApiAlgTest extends FunSuite {
     assertEquals(prOut, expected)
   }
 
+  test("commentPullRequest") {
+    val reference = gitlabApiAlg.referencePullRequest(PullRequestNumber(1347))
+    assertEquals(reference, "!1347")
+  }
+
+  test("commentPullRequest") {
+    val comment = gitlabApiAlg
+      .commentPullRequest(Repo("foo", "bar"), PullRequestNumber(150), "Superseded by #1234")
+      .unsafeRunSync()
+    assertEquals(comment, Comment("Superseded by #1234"))
+  }
+
   val getMr = json"""
     {
       "id": 26328,