Repository: scala-steward-org/scala-steward
Pull request #1557 (2020-07-31 16:30:21)

Gitlab merge when pipeline succeeds
This adds a configurable option to use the gitlab API's merge automatically when pipeline succeeds functionality on MRs created by scala-steward.  

I realize this may be too specialized to be accepted, and that stuff like this may warrant a reconsideration around config.  I'm more than happy to help in any of these efforts to get this contributed, let us know what you think!


Patch:
diff --git a/modules/core/src/main/scala/org/scalasteward/core/application/Cli.scala b/modules/core/src/main/scala/org/scalasteward/core/application/Cli.scala
index 86640b64..f0fc1adc 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/application/Cli.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/application/Cli.scala
@@ -56,7 +56,8 @@ object Cli {
       groupMigrations: Option[String] = None,
       cacheTtl: FiniteDuration = 2.hours,
       cacheMissDelay: FiniteDuration = 0.milliseconds,
-      bitbucketServerUseDefaultReviewers: Boolean = false
+      bitbucketServerUseDefaultReviewers: Boolean = false,
+      gitlabMergeWhenPipelineSucceeds: Boolean = false
   )
 
   final case class EnvVar(name: String, value: String)
diff --git a/modules/core/src/main/scala/org/scalasteward/core/application/Config.scala b/modules/core/src/main/scala/org/scalasteward/core/application/Config.scala
index b194cb77..6930a3eb 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/application/Config.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/application/Config.scala
@@ -72,7 +72,8 @@ final case class Config(
     groupMigrations: Option[File],
     cacheTtl: FiniteDuration,
     cacheMissDelay: FiniteDuration,
-    bitbucketServerUseDefaultReviewers: Boolean
+    bitbucketServerUseDefaultReviewers: Boolean,
+    gitlabMergeWhenPipelineSucceeds: Boolean
 ) {
   def vcsUser[F[_]](implicit F: Sync[F]): F[AuthenticatedUser] = {
     val urlWithUser = util.uri.withUserInfo.set(UserInfo(vcsLogin, None))(vcsApiHost).renderString
@@ -108,7 +109,8 @@ object Config {
         groupMigrations = args.groupMigrations.map(_.toFile),
         cacheTtl = args.cacheTtl,
         cacheMissDelay = args.cacheMissDelay,
-        bitbucketServerUseDefaultReviewers = args.bitbucketServerUseDefaultReviewers
+        bitbucketServerUseDefaultReviewers = args.bitbucketServerUseDefaultReviewers,
+        gitlabMergeWhenPipelineSucceeds = args.gitlabMergeWhenPipelineSucceeds
       )
     }
 }
diff --git a/modules/core/src/main/scala/org/scalasteward/core/gitlab/Url.scala b/modules/core/src/main/scala/org/scalasteward/core/gitlab/Url.scala
index 2b987501..e6cab79f 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/gitlab/Url.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/gitlab/Url.scala
@@ -35,6 +35,13 @@ class Url(apiHost: Uri) {
   def mergeRequest(repo: Repo): Uri =
     repos(repo) / "merge_requests"
 
+  def existingMergeRequest(repo: Repo, internalId: Int): Uri =
+    mergeRequest(repo) / internalId.toString()
+
+  def mergeWhenPiplineSucceeds(repo: Repo, internalId: Int) =
+    (existingMergeRequest(repo, internalId) / "merge")
+      .withQueryParam("merge_when_pipeline_succeeds", "true")
+
   def listMergeRequests(repo: Repo, source: String, target: String): Uri =
     mergeRequest(repo)
       .withQueryParam("source_branch", source)
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 3cca9a84..525f1e4c 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
@@ -17,6 +17,7 @@
 package org.scalasteward.core.gitlab.http4s
 
 import cats.syntax.all._
+import io.chrisdavenport.log4cats.Logger
 import io.circe._
 import io.circe.generic.semiauto._
 import org.http4s.{Request, Status, Uri}
@@ -36,22 +37,33 @@ final private[http4s] case class MergeRequestPayload(
     source_branch: String,
     target_branch: Branch
 )
+
 private[http4s] object MergeRequestPayload {
   def apply(id: String, projectId: Long, data: NewPullRequestData): MergeRequestPayload =
     MergeRequestPayload(id, data.title, data.body, projectId, data.head, data.base)
 }
+
 final private[http4s] case class MergeRequestOut(
-    web_url: Uri,
+    webUrl: Uri,
     state: PullRequestState,
-    title: String
+    title: String,
+    iid: Int,
+    mergeStatus: String
 ) {
-  val pullRequestOut: PullRequestOut = PullRequestOut(web_url, state, title)
+  val pullRequestOut: PullRequestOut = PullRequestOut(webUrl, state, title)
 }
+
 final private[http4s] case class CommitId(id: Sha1) {
   val commitOut: CommitOut = CommitOut(id)
 }
 final private[http4s] case class ProjectId(id: Long)
 
+private[http4s] object GitlabMergeStatus {
+  val CanBeMerged = "can_be_merged"
+  val CannotBeMerged = "cannot_be_merged"
+  val Checking = "checking"
+}
+
 private[http4s] object GitlabJsonCodec {
   // prevent IntelliJ from removing the import of uriDecoder
   locally(uriDecoder)
@@ -78,21 +90,34 @@ private[http4s] object GitlabJsonCodec {
     } yield RepoOut(name, owner, parent, cloneUrl, defaultBranch)
   }
 
+  implicit val mergeRequestOutDecoder: Decoder[MergeRequestOut] = Decoder.instance { c =>
+    for {
+      webUrl <- c.downField("web_url").as[Uri]
+      state <- c.downField("state").as[PullRequestState]
+      title <- c.downField("title").as[String]
+      iid <- c.downField("iid").as[Int]
+      mergeStatus <- c.downField("merge_status").as[String]
+    } yield MergeRequestOut(webUrl, state, title, iid, mergeStatus)
+  }
+
   implicit val projectIdDecoder: Decoder[ProjectId] = deriveDecoder
   implicit val mergeRequestPayloadEncoder: Encoder[MergeRequestPayload] = deriveEncoder
-  implicit val PullRequestOutDecoder: Decoder[PullRequestOut] =
-    deriveDecoder[MergeRequestOut].map(_.pullRequestOut)
+  implicit val pullRequestOutDecoder: Decoder[PullRequestOut] =
+    mergeRequestOutDecoder.map(_.pullRequestOut)
   implicit val commitOutDecoder: Decoder[CommitOut] = deriveDecoder[CommitId].map(_.commitOut)
   implicit val branchOutDecoder: Decoder[BranchOut] = deriveDecoder[BranchOut]
 }
 
-class Http4sGitLabApiAlg[F[_]: MonadThrowable](
+class Http4sGitLabApiAlg[F[_]](
     gitlabApiHost: Uri,
     user: AuthenticatedUser,
     modify: Repo => Request[F] => F[Request[F]],
-    doNotFork: Boolean
+    doNotFork: Boolean,
+    mergeWhenPipelineSucceeds: Boolean
 )(implicit
-    client: HttpJsonClient[F]
+    client: HttpJsonClient[F],
+    logger: Logger[F],
+    monad: MonadThrowable[F]
 ) extends VCSApiAlg[F] {
   import GitlabJsonCodec._
 
@@ -116,15 +141,54 @@ class Http4sGitLabApiAlg[F[_]: MonadThrowable](
 
   def createPullRequest(repo: Repo, data: NewPullRequestData): F[PullRequestOut] = {
     val targetRepo = if (doNotFork) repo else repo.copy(owner = user.login)
-    for {
+    val mergeRequest = for {
       projectId <- client.get[ProjectId](url.repos(repo), modify(repo))
       payload = MergeRequestPayload(url.encodedProjectId(targetRepo), projectId.id, data)
-      res <- client.postWithBody[PullRequestOut, MergeRequestPayload](
+      res <- client.postWithBody[MergeRequestOut, MergeRequestPayload](
         url.mergeRequest(targetRepo),
         payload,
         modify(repo)
       )
     } yield res
+
+    def waitForMergeRequestStatus(internalId: Int, retries: Int = 10): F[MergeRequestOut] =
+      client
+        .get[MergeRequestOut](url.existingMergeRequest(repo, internalId), modify(repo))
+        .flatMap {
+          case mr if (mr.mergeStatus =!= GitlabMergeStatus.Checking) => monad.pure(mr)
+          case _ if (retries > 0)                                    => waitForMergeRequestStatus(internalId, retries - 1)
+          case other                                                 => monad.pure(other)
+        }
+
+    val updatedMergeRequest =
+      if (!mergeWhenPipelineSucceeds)
+        mergeRequest
+      else
+        mergeRequest
+          .flatMap(mr => waitForMergeRequestStatus(mr.iid))
+          .flatMap {
+            case mr if (mr.mergeStatus === GitlabMergeStatus.CanBeMerged) =>
+              for {
+                _ <- logger.info(s"Setting ${mr.webUrl} to merge when pipeline succeeds")
+                res <-
+                  client
+                    .put[MergeRequestOut](
+                      url.mergeWhenPiplineSucceeds(repo, mr.iid),
+                      modify(repo)
+                    )
+                    // it's possible that our status changed from can be merged already,
+                    // so just handle it gracefully and proceed without setting auto merge.
+                    .recoverWith { case UnexpectedResponse(_, _, _, status, _) =>
+                      logger
+                        .warn(s"Unexpected gitlab response setting auto merge: $status")
+                        .as(mr)
+                    }
+              } yield res
+            case mr =>
+              logger.info(s"Unable to automatically merge ${mr.webUrl}").map(_ => mr)
+          }
+
+    updatedMergeRequest.map(_.pullRequestOut)
   }
 
   def getBranch(repo: Repo, branch: Branch): F[BranchOut] =
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 ce3b183f..fdf20b25 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,7 +19,7 @@ package org.scalasteward.core.util
 import cats.effect.Sync
 import cats.syntax.all._
 import io.circe.{Decoder, Encoder}
-import org.http4s.Method.{GET, POST}
+import org.http4s.Method.{GET, POST, PUT}
 import org.http4s.circe.{jsonEncoderOf, jsonOf}
 import org.http4s.client.Client
 import org.http4s._
@@ -36,6 +36,9 @@ final class HttpJsonClient[F[_]: Sync](implicit
   def post[A: Decoder](uri: Uri, modify: ModReq): F[A] =
     request[A](POST, uri, modify)
 
+  def put[A: Decoder](uri: Uri, modify: ModReq): F[A] =
+    request[A](PUT, 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])))
 
diff --git a/modules/core/src/main/scala/org/scalasteward/core/vcs/VCSSelection.scala b/modules/core/src/main/scala/org/scalasteward/core/vcs/VCSSelection.scala
index bfaac0fd..0921a416 100644
--- a/modules/core/src/main/scala/org/scalasteward/core/vcs/VCSSelection.scala
+++ b/modules/core/src/main/scala/org/scalasteward/core/vcs/VCSSelection.scala
@@ -25,17 +25,28 @@ import org.scalasteward.core.github.http4s.Http4sGitHubApiAlg
 import org.scalasteward.core.gitlab.http4s.Http4sGitLabApiAlg
 import org.scalasteward.core.util.HttpJsonClient
 import org.scalasteward.core.vcs.data.AuthenticatedUser
+import io.chrisdavenport.log4cats.Logger
 
-class VCSSelection[F[_]: Sync](implicit client: HttpJsonClient[F], user: AuthenticatedUser) {
+class VCSSelection[F[_]: Sync](implicit
+    client: HttpJsonClient[F],
+    user: AuthenticatedUser,
+    logger: Logger[F]
+) {
   private def github(config: Config): Http4sGitHubApiAlg[F] = {
     import org.scalasteward.core.github.http4s.authentication.addCredentials
 
     new Http4sGitHubApiAlg[F](config.vcsApiHost, _ => addCredentials(user))
   }
+
   private def gitlab(config: Config): Http4sGitLabApiAlg[F] = {
     import org.scalasteward.core.gitlab.http4s.authentication.addCredentials
-
-    new Http4sGitLabApiAlg[F](config.vcsApiHost, user, _ => addCredentials(user), config.doNotFork)
+    new Http4sGitLabApiAlg[F](
+      config.vcsApiHost,
+      user,
+      _ => addCredentials(user),
+      config.doNotFork,
+      config.gitlabMergeWhenPipelineSucceeds
+    )
   }
 
   private def bitbucket(config: Config): Http4sBitbucketApiAlg[F] = {


Test patch:
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 2fae305a..d18e6dad 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
@@ -18,10 +18,16 @@ import org.scalasteward.core.util.{HttpJsonClient, Nel}
 import org.scalasteward.core.vcs.data._
 import org.scalatest.funsuite.AnyFunSuite
 import org.scalatest.matchers.should.Matchers
+import io.chrisdavenport.log4cats.slf4j.Slf4jLogger
+import org.http4s.headers.Allow
+import cats.implicits._
 
 class Http4sGitlabApiAlgTest extends AnyFunSuite with Matchers {
   import GitlabJsonCodec._
 
+  object MergeWhenPipelineSucceedsMatcher
+      extends QueryParamDecoderMatcher[Boolean]("merge_when_pipeline_succeeds")
+
   val routes: HttpRoutes[IO] =
     HttpRoutes.of[IO] {
       case GET -> Root / "projects" / "foo/bar" =>
@@ -37,6 +43,21 @@ class Http4sGitlabApiAlgTest extends AnyFunSuite with Matchers {
           )
         )
 
+      case GET -> Root / "projects" / "foo/bar" / "merge_requests" / "150" =>
+        Ok(
+          getMr.deepMerge(
+            json""" { "iid": 150, "web_url": "https://gitlab.com/foo/bar/merge_requests/150" } """
+          )
+        )
+
+      case PUT -> Root / "projects" / "foo/bar" / "merge_requests" / "150" / "merge"
+          :? MergeWhenPipelineSucceedsMatcher(_) =>
+        Ok(
+          getMr.deepMerge(
+            json""" { "iid": 150, "web_url": "https://gitlab.com/foo/bar/merge_requests/150" } """
+          )
+        )
+
       case req =>
         println(req.toString())
         NotFound()
@@ -44,8 +65,15 @@ class Http4sGitlabApiAlgTest extends AnyFunSuite with Matchers {
 
   implicit val client: Client[IO] = Client.fromHttpApp(routes.orNotFound)
   implicit val httpJsonClient: HttpJsonClient[IO] = new HttpJsonClient[IO]
+  implicit val logger = Slf4jLogger.getLogger[IO]
   val gitlabApiAlg =
-    new Http4sGitLabApiAlg[IO](config.vcsApiHost, user, _ => IO.pure, doNotFork = false)
+    new Http4sGitLabApiAlg[IO](
+      config.vcsApiHost,
+      user,
+      _ => IO.pure,
+      doNotFork = false,
+      mergeWhenPipelineSucceeds = false
+    )
 
   val data = UpdateData(
     Repo("foo", "bar"),
@@ -74,7 +102,13 @@ class Http4sGitlabApiAlgTest extends AnyFunSuite with Matchers {
 
   test("createPullRequest -- no fork") {
     val gitlabApiAlgNoFork =
-      new Http4sGitLabApiAlg[IO](config.vcsApiHost, user, _ => IO.pure, doNotFork = true)
+      new Http4sGitLabApiAlg[IO](
+        config.vcsApiHost,
+        user,
+        _ => IO.pure,
+        doNotFork = true,
+        mergeWhenPipelineSucceeds = false
+      )
     val prOut =
       gitlabApiAlgNoFork
         .createPullRequest(Repo("foo", "bar"), newPRData)
@@ -91,6 +125,61 @@ class Http4sGitlabApiAlgTest extends AnyFunSuite with Matchers {
     decode[ProjectId](getRepo.spaces2) shouldBe Right(ProjectId(12414871L))
   }
 
+  test("createPullRequest -- auto merge") {
+    val gitlabApiAlgNoFork =
+      new Http4sGitLabApiAlg[IO](
+        config.vcsApiHost,
+        user,
+        _ => IO.pure,
+        doNotFork = true,
+        mergeWhenPipelineSucceeds = true
+      )
+
+    val prOut =
+      gitlabApiAlgNoFork
+        .createPullRequest(Repo("foo", "bar"), newPRData)
+        .unsafeRunSync()
+
+    prOut shouldBe PullRequestOut(
+      uri"https://gitlab.com/foo/bar/merge_requests/150",
+      PullRequestState.Open,
+      "title"
+    )
+  }
+
+  test("createPullRequest -- don't fail on error code") {
+    val errorClient: Client[IO] =
+      Client.fromHttpApp(
+        (HttpRoutes.of[IO] {
+          case PUT -> Root / "projects" / "foo/bar" / "merge_requests" / "150" / "merge"
+              :? MergeWhenPipelineSucceedsMatcher(_) =>
+            MethodNotAllowed(Allow(OPTIONS, GET, HEAD))
+        } <+> routes).orNotFound
+      )
+    val errorJsonClient: HttpJsonClient[IO] =
+      new HttpJsonClient[IO]()(implicitly, errorClient)
+
+    val gitlabApiAlgNoFork =
+      new Http4sGitLabApiAlg[IO](
+        config.vcsApiHost,
+        user,
+        _ => IO.pure,
+        doNotFork = true,
+        mergeWhenPipelineSucceeds = true
+      )(errorJsonClient, implicitly, implicitly)
+
+    val prOut =
+      gitlabApiAlgNoFork
+        .createPullRequest(Repo("foo", "bar"), newPRData)
+        .unsafeRunSync()
+
+    prOut shouldBe PullRequestOut(
+      uri"https://gitlab.com/foo/bar/merge_requests/150",
+      PullRequestState.Open,
+      "title"
+    )
+  }
+
   val getMr = json"""
     {
       "id": 26328,
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 3495d3b7..fe93bb2e 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
@@ -54,7 +54,8 @@ object MockContext {
     groupMigrations = None,
     cacheTtl = 1.hour,
     cacheMissDelay = 0.milliseconds,
-    bitbucketServerUseDefaultReviewers = false
+    bitbucketServerUseDefaultReviewers = false,
+    gitlabMergeWhenPipelineSucceeds = false
   )
 
   implicit val mockEffBracketThrowable: BracketThrowable[MockEff] = Sync[MockEff]