diff --git a/bootstrap/src/main/resources/reflect-config.json b/bootstrap/src/main/resources/reflect-config.json index 866c93ba..8a2ce523 100644 --- a/bootstrap/src/main/resources/reflect-config.json +++ b/bootstrap/src/main/resources/reflect-config.json @@ -135,6 +135,24 @@ } ] }, + { + "name": "org.polyvariant.Gitlab$$anon$2", + "fields": [ + { + "name": "0bitmap$1", + "allowUnsafeAccess": true + } + ] + }, + { + "name": "org.polyvariant.Gitlab$Webhook$", + "fields": [ + { + "name": "0bitmap$2", + "allowUnsafeAccess": true + } + ] + }, { "name": "sttp.model.Header$", "fields": [ diff --git a/bootstrap/src/main/scala/org/polyvariant/Gitlab.scala b/bootstrap/src/main/scala/org/polyvariant/Gitlab.scala index 1d89678b..20f15de1 100644 --- a/bootstrap/src/main/scala/org/polyvariant/Gitlab.scala +++ b/bootstrap/src/main/scala/org/polyvariant/Gitlab.scala @@ -2,10 +2,11 @@ package org.polyvariant import cats.implicits.* -import scala.util.chaining._ +import scala.util.chaining.* import io.pg.gitlab.graphql.* import sttp.model.Uri import sttp.client3.* +import sttp.client3.circe.* import caliban.client.SelectionBuilder import caliban.client.CalibanClientError.DecodingError import io.pg.gitlab.graphql.MergeRequest @@ -20,15 +21,19 @@ import io.pg.gitlab.graphql.UserCore import caliban.client.Operations.IsOperation import sttp.model.Method import cats.MonadThrow +import io.circe.* trait Gitlab[F[_]] { def mergeRequests(projectId: Long): F[List[Gitlab.MergeRequestInfo]] def deleteMergeRequest(projectId: Long, mergeRequestId: Long): F[Unit] def createWebhook(projectId: Long, pitgullUrl: Uri): F[Unit] + def listWebhooks(projectId: Long): F[List[Gitlab.Webhook]] } object Gitlab { + def apply[F[_]](using ev: Gitlab[F]): Gitlab[F] = ev + def sttpInstance[F[_]: Logger: MonadThrow]( baseUri: Uri, accessToken: String @@ -36,7 +41,11 @@ object Gitlab { using backend: SttpBackend[Identity, Any] // FIXME: https://github.com/polyvariant/pitgull/issues/265 ): Gitlab[F] = { def runRequest[O](request: Request[O, Any]): F[O] = - request.header("Private-Token", accessToken).send(backend).pure[F].map(_.body) // FIXME - change in https://github.com/polyvariant/pitgull/issues/265 + request + .header("Private-Token", accessToken) + .send(backend) + .pure[F] + .map(_.body) // FIXME - change in https://github.com/polyvariant/pitgull/issues/265 def runGraphQLQuery[A: IsOperation, B](a: SelectionBuilder[A, B]): F[B] = runRequest(a.toRequest(baseUri.addPath("api", "graphql"))).rethrow @@ -52,47 +61,74 @@ object Gitlab { } def deleteMergeRequest(projectId: Long, mergeRequestId: Long): F[Unit] = for { - _ <- Logger[F].debug(s"Request to remove $mergeRequestId") + _ <- Logger[F].debug(s"Request to remove $mergeRequestId") result <- runRequest( - basicRequest.delete( - baseUri - .addPath( - Seq( - "api", - "v4", - "projects", - projectId.toString, - "merge_requests", - mergeRequestId.toString - ) - ) - ) - ) + basicRequest.delete( + baseUri + .addPath( + Seq( + "api", + "v4", + "projects", + projectId.toString, + "merge_requests", + mergeRequestId.toString + ) + ) + ) + ) } yield () - + def createWebhook(projectId: Long, pitgullUrl: Uri): F[Unit] = for { - _ <- Logger[F].debug(s"Creating webhook to $pitgullUrl") + _ <- Logger[F].debug(s"Creating webhook to $pitgullUrl") result <- runRequest( - basicRequest.post( - baseUri - .addPath( - Seq( - "api", - "v4", - "projects", - projectId.toString, - "hooks" - ) - ) - ) - .body(s"""{"merge_requests_events": true, "pipeline_events": true, "note_events": true, "url": "$pitgullUrl"}""") - .contentType("application/json") - ) + basicRequest + .post( + baseUri + .addPath( + Seq( + "api", + "v4", + "projects", + projectId.toString, + "hooks" + ) + ) + ) + .body(s"""{"merge_requests_events": true, "pipeline_events": true, "note_events": true, "url": "$pitgullUrl"}""") + .contentType("application/json") + ) } yield () + + def listWebhooks(projectId: Long): F[List[Webhook]] = for { + _ <- Logger[F].debug(s"Listing webhooks for $projectId") + response <- runRequest( + basicRequest + .get( + baseUri + .addPath( + Seq( + "api", + "v4", + "projects", + projectId.toString, + "hooks" + ) + ) + ) + .response(asJson[List[Webhook]]) + ).flatMap(_.liftTo[F]) + _ <- Logger[F].debug(response.toString) + } yield response } } + final case class Webhook( + id: Long, + url: String + ) derives Codec.AsObject + final case class MergeRequestInfo( projectId: Long, mergeRequestIid: Long, diff --git a/bootstrap/src/main/scala/org/polyvariant/Main.scala b/bootstrap/src/main/scala/org/polyvariant/Main.scala index 52623280..8afbbab5 100644 --- a/bootstrap/src/main/scala/org/polyvariant/Main.scala +++ b/bootstrap/src/main/scala/org/polyvariant/Main.scala @@ -12,6 +12,7 @@ import sttp.monad.MonadError import cats.MonadThrow import org.polyvariant.Config.ArgumentsParsingException import cats.effect.std.Console +import cats.Monad object Main extends IOApp { @@ -20,35 +21,51 @@ object Main extends IOApp { Logger[F].info(s"ID: ${mr.mergeRequestIid} by: ${mr.authorUsername}") }.void - private def readConsent[F[_]: Console: Applicative]: F[Boolean] = - Console[F].readLine.map(_.toLowerCase == "y") + private def readConsent[F[_]: Console: MonadThrow]: F[Unit] = + MonadThrow[F] + .ifM(Console[F].readLine.map(_.trim.toLowerCase == "y"))( + ifTrue = MonadThrow[F].pure(()), + ifFalse = MonadThrow[F].raiseError(new Exception("User rejected deletion")) + ) private def qualifyMergeRequestsForDeletion(botUserName: String, mergeRequests: List[MergeRequestInfo]): List[MergeRequestInfo] = mergeRequests.filter(_.authorUsername == botUserName) - private def program[F[_]: Logger: Console: Async: MonadThrow](args: List[String]): F[Unit] = { + private def deleteMergeRequests[F[_]: Gitlab: Logger: Applicative](project: Long, mergeRequests: List[MergeRequestInfo]): F[Unit] = + mergeRequests.traverse(mr => Gitlab[F].deleteMergeRequest(project, mr.mergeRequestIid)).void + + private def createWebhook[F[_]: Gitlab: Logger: Applicative](project: Long, webhook: Uri): F[Unit] = + Logger[F].info("Creating webhook") *> + Gitlab[F].createWebhook(project, webhook) *> + Logger[F].info("Webhook created") + + private def configureWebhooks[F[_]: Gitlab: Logger: Monad](project: Long, webhook: Uri): F[Unit] = for { + hooks <- Gitlab[F].listWebhooks(project).map(_.filter(_.url == webhook.toString)) + _ <- Monad[F] + .ifM(hooks.nonEmpty.pure[F])( + ifTrue = Logger[F].success("Webhook already exists"), + ifFalse = createWebhook(project, webhook) + ) + } yield () + + private def program[F[_]: Logger: Console: Async](args: List[String]): F[Unit] = { given SttpBackend[Identity, Any] = HttpURLConnectionBackend() val parsedArgs = Args.parse(args) for { config <- Config.fromArgs(parsedArgs) _ <- Logger[F].info("Starting pitgull bootstrap!") - gitlab = Gitlab.sttpInstance[F](config.gitlabUri, config.token) - mrs <- gitlab.mergeRequests(config.project) + given Gitlab[F] = Gitlab.sttpInstance[F](config.gitlabUri, config.token) + mrs <- Gitlab[F].mergeRequests(config.project) _ <- Logger[F].info(s"Merge requests found: ${mrs.length}") _ <- printMergeRequests(mrs) botMrs = qualifyMergeRequestsForDeletion(config.botUser, mrs) _ <- Logger[F].info(s"Will delete merge requests: ${botMrs.map(_.mergeRequestIid).mkString(", ")}") _ <- Logger[F].info("Do you want to proceed? y/Y") - _ <- MonadThrow[F] - .ifM(readConsent)( - ifTrue = MonadThrow[F].pure(()), - ifFalse = MonadThrow[F].raiseError(new Exception("User rejected deletion")) - ) - _ <- botMrs.traverse(mr => gitlab.deleteMergeRequest(config.project, mr.mergeRequestIid)) + _ <- readConsent + _ <- deleteMergeRequests(config.project, botMrs) _ <- Logger[F].info("Done processing merge requests") - _ <- Logger[F].info("Creating webhook") - _ <- gitlab.createWebhook(config.project, config.pitgullWebhookUrl) - _ <- Logger[F].info("Webhook created") + _ <- Logger[F].info("Configuring webhook") + _ <- configureWebhooks(config.project, config.pitgullWebhookUrl) _ <- Logger[F].success("Bootstrap finished") } yield () } diff --git a/build.sbt b/build.sbt index d14e4dd6..b9274883 100644 --- a/build.sbt +++ b/build.sbt @@ -115,6 +115,8 @@ lazy val bootstrap = project "org.typelevel" %% "cats-effect" % "3.1.1", "com.kubukoz" %% "caliban-gitlab" % "0.1.0", "com.softwaremill.sttp.client3" %% "core" % "3.3.6", + "com.softwaremill.sttp.client3" %% "circe" % "3.3.6", + "io.circe" %% "circe-core" % "0.14.1", crossPlugin("com.kubukoz" % "better-tostring" % "0.3.3") ), publish / skip := true,