├── .sbtopts ├── docker ├── repos.md └── docker-compose.yml ├── project ├── build.properties └── plugins.sbt ├── .gitignore ├── dhall ├── pitgull.dhall ├── core.dhall └── json.dhall ├── .mergify.yml ├── core └── src │ └── main │ └── scala │ └── io │ └── pg │ ├── TextUtils.scala │ ├── background │ └── background.scala │ └── messaging │ └── messaging.scala ├── src ├── main │ ├── java │ │ └── org │ │ │ └── slf4j │ │ │ └── impl │ │ │ └── StaticLoggerBinder.java │ └── scala │ │ └── io │ │ └── pg │ │ ├── transport │ │ └── transport.scala │ │ ├── OdinInterop.scala │ │ ├── config │ │ ├── DiscriminatedCodec.scala │ │ ├── format.scala │ │ └── ProjectConfig.scala │ │ ├── MergeRequests.scala │ │ ├── appconfig.scala │ │ ├── Application.scala │ │ ├── webhook │ │ └── webhook.scala │ │ ├── resolver.scala │ │ ├── Main.scala │ │ └── actions.scala └── test │ └── scala │ └── io │ └── pg │ ├── fakes │ ├── FakeUtils.scala │ ├── ProjectConfigReaderFake.scala │ └── ProjectActionsStateFake.scala │ ├── MainTest.scala │ ├── ProjectConfigFormatTests.scala │ └── WebhookProcessorTest.scala ├── example.dhall ├── docs └── basic-flow.uml ├── gitlab └── src │ ├── main │ └── scala │ │ └── io │ │ └── pg │ │ └── gitlab │ │ ├── webhook │ │ └── webhook.scala │ │ └── Gitlab.scala │ └── test │ └── scala │ └── io │ └── pg │ └── gitlab │ └── webhook │ └── WebhookFormatTests.scala ├── LICENSE.md ├── bootstrap └── src │ └── main │ ├── scala │ └── org │ │ └── polyvariant │ │ ├── Args.scala │ │ ├── Logger.scala │ │ ├── Config.scala │ │ ├── Main.scala │ │ └── Gitlab.scala │ └── resources │ └── reflect-config.json ├── .scalafmt.conf ├── .github └── workflows │ ├── clean.yml │ └── ci.yml └── README.md /.sbtopts: -------------------------------------------------------------------------------- 1 | -J-Xmx2g 2 | -------------------------------------------------------------------------------- /docker/repos.md: -------------------------------------------------------------------------------- 1 | - kubukoz/demo 2 | -------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=1.7.3 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .metals/ 2 | *.class 3 | .idea 4 | .bloop 5 | target/ 6 | **/secret.conf 7 | **/.DS_Store 8 | .vscode/ 9 | **/*.worksheet.sc 10 | docker/pass.sh 11 | **/.bsp 12 | -------------------------------------------------------------------------------- /dhall/pitgull.dhall: -------------------------------------------------------------------------------- 1 | let pg = ./core.dhall 2 | 3 | let projectToJson = ./json.dhall 4 | 5 | in { text = pg.text 6 | , match = pg.match 7 | , Rule = pg.Rule 8 | , action = pg.action 9 | , projectToJson 10 | } 11 | -------------------------------------------------------------------------------- /.mergify.yml: -------------------------------------------------------------------------------- 1 | pull_request_rules: 2 | - name: automatically merge Scala Steward PRs on CI success 3 | conditions: 4 | - author=scala-steward 5 | - status-success~=Build and Test 6 | - body~=labels:.*semver-patch.* 7 | actions: 8 | merge: 9 | method: merge 10 | -------------------------------------------------------------------------------- /core/src/main/scala/io/pg/TextUtils.scala: -------------------------------------------------------------------------------- 1 | package io.pg 2 | 3 | object TextUtils { 4 | 5 | def trim(maxChars: Int)(s: String): String = { 6 | val ellipsis = "." * 3 7 | if (s.lengthIs > maxChars) s.take(maxChars - ellipsis.length) ++ ellipsis 8 | else s 9 | } 10 | 11 | def inline(s: String): String = 12 | s.replaceAll("\n", " ") 13 | 14 | } 15 | -------------------------------------------------------------------------------- /src/main/java/org/slf4j/impl/StaticLoggerBinder.java: -------------------------------------------------------------------------------- 1 | package org.slf4j.impl; 2 | 3 | public class StaticLoggerBinder extends io.pg.OdinInterop { 4 | public static String REQUESTED_API_VERSION = "1.7"; 5 | 6 | private static final StaticLoggerBinder _instance = new StaticLoggerBinder(); 7 | public static StaticLoggerBinder getSingleton() { 8 | return _instance; 9 | } 10 | } 11 | 12 | -------------------------------------------------------------------------------- /example.dhall: -------------------------------------------------------------------------------- 1 | let pg = 2 | https://raw.githubusercontent.com/pitgull/pitgull/v0.0.2/dhall/pitgull.dhall sha256:65a46e78c2d4aac7cd3afeb1fa209ed244dc60644634a9cfc61800ea3417ea9b 3 | 4 | let wms = 5 | https://gitlab.com/kubukoz/demo/-/raw/db4686f29bab1bc056ec96307a39aa3dd6337173/wms.dhall sha256:4b9218b9a1a83262550b9bdfa7d7250f4aa365b8d8c2131f65517ef5f3eeb68c 6 | 7 | in pg.projectToJson { rules = [ wms.scalaSteward ] } 8 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("io.github.davidgregory084" % "sbt-tpolecat" % "0.1.22") 2 | addSbtPlugin("com.github.sbt" % "sbt-native-packager" % "1.9.16") 3 | addSbtPlugin("com.codecommit" % "sbt-github-actions" % "0.13.0") 4 | addSbtPlugin("com.eed3si9n" % "sbt-buildinfo" % "0.11.0") 5 | addSbtPlugin("com.dwijnand" % "sbt-dynver" % "4.1.1") 6 | addSbtPlugin("ch.epfl.scala" % "sbt-missinglink" % "0.3.3") 7 | addSbtPlugin("org.scalameta" % "sbt-native-image" % "0.3.2") 8 | -------------------------------------------------------------------------------- /src/test/scala/io/pg/fakes/FakeUtils.scala: -------------------------------------------------------------------------------- 1 | package io.pg.fakes 2 | 3 | import cats.Monad 4 | import cats.mtl.Stateful 5 | import cats.effect.Ref 6 | 7 | object FakeUtils { 8 | 9 | def statefulRef[F[_]: Monad, A](ref: Ref[F, A]): Stateful[F, A] = 10 | new Stateful[F, A] { 11 | def monad: Monad[F] = implicitly 12 | def get: F[A] = ref.get 13 | def set(s: A): F[Unit] = ref.set(s) 14 | override def modify(f: A => A): F[Unit] = ref.update(f) 15 | } 16 | 17 | } 18 | -------------------------------------------------------------------------------- /docs/basic-flow.uml: -------------------------------------------------------------------------------- 1 | @startuml 2 | !theme bluegray 3 | note over Gitlab: Pipeline/Merge request/Note event in project 4 | Gitlab->Pitgull: Webhook 5 | Pitgull->Gitlab: Ok 6 | note over Pitgull: Identify project 7 | note over Pitgull: Read merging rules for project 8 | Pitgull->Gitlab: List merge requests for project 9 | Gitlab->Pitgull: Merge requests for project 10 | note over Pitgull: Order MRs by mergeability, select first MR 11 | note over Pitgull: Decide what to do with selected MR 12 | Pitgull->Gitlab: Rebase/Accept MR 13 | @enduml -------------------------------------------------------------------------------- /gitlab/src/main/scala/io/pg/gitlab/webhook/webhook.scala: -------------------------------------------------------------------------------- 1 | package io.pg.gitlab.webhook 2 | 3 | import io.circe.Codec 4 | 5 | final case class WebhookEvent(project: Project, objectKind: String /* for logs */ ) 6 | 7 | object WebhookEvent { 8 | // todo: use configured codec when https://github.com/circe/circe/pull/1800 is available 9 | given Codec[WebhookEvent] = Codec.forProduct2("project", "object_kind")(apply)(we => (we.project, we.objectKind)) 10 | } 11 | 12 | final case class Project( 13 | id: Long 14 | ) derives Codec.AsObject 15 | 16 | object Project { 17 | val demo = Project(20190338) 18 | } 19 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright 2020 Jakub Kozłowski 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /src/test/scala/io/pg/MainTest.scala: -------------------------------------------------------------------------------- 1 | package io.pg 2 | 3 | import cats.arrow.FunctionK 4 | import ciris.Secret 5 | import sttp.model.Uri._ 6 | import weaver.SimpleIOSuite 7 | 8 | object MainTest extends SimpleIOSuite { 9 | test("Application starts") { 10 | val testConfig = AppConfig( 11 | http = HttpConfig(8080), 12 | meta = MetaConfig("-", BuildInfo.version, BuildInfo.scalaVersion), 13 | git = Git(Git.Host.Gitlab, uri"http://localhost", Secret("token")), 14 | queues = Queues(10), 15 | middleware = MiddlewareConfig(Set()) 16 | ) 17 | 18 | Main.serve(FunctionK.id)(testConfig).use_.as(success) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/main/scala/io/pg/transport/transport.scala: -------------------------------------------------------------------------------- 1 | package io.pg.transport 2 | 3 | import io.circe.Codec 4 | 5 | final case class MergeRequestState( 6 | projectId: Long, 7 | mergeRequestIid: Long, 8 | authorUsername: String, 9 | description: Option[String], 10 | status: MergeRequestState.Status, 11 | mergeability: MergeRequestState.Mergeability 12 | ) derives Codec.AsObject 13 | 14 | object MergeRequestState { 15 | 16 | enum Status derives Codec.AsObject { 17 | case Success 18 | case Other(value: String) 19 | } 20 | 21 | enum Mergeability derives Codec.AsObject { 22 | case CanMerge, NeedsRebase, HasConflicts 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /docker/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.0" 2 | services: 3 | steward: 4 | image: "fthomas/scala-steward:latest" 5 | command: > 6 | --vcs-login ignored 7 | --git-author-email scala-steward@example.com 8 | --workspace /opt/scala-steward/workspace 9 | --repos-file /opt/scala-steward/repos.md 10 | --vcs-type gitlab 11 | --vcs-api-host https://gitlab.com/api/v4 12 | --git-ask-pass /opt/scala-steward/.gitlab/askpass/pass.sh 13 | --do-not-fork 14 | volumes: 15 | - "steward-data:/opt/scala-steward" 16 | - "./repos.md:/opt/scala-steward/repos.md" 17 | - "./pass.sh:/opt/scala-steward/.gitlab/askpass/pass.sh" 18 | 19 | volumes: 20 | steward-data: 21 | -------------------------------------------------------------------------------- /src/main/scala/io/pg/OdinInterop.scala: -------------------------------------------------------------------------------- 1 | package io.pg 2 | 3 | import cats.effect.IO 4 | import cats.effect.kernel.Sync 5 | import cats.effect.std.Dispatcher 6 | import cats.effect.unsafe.implicits._ 7 | import io.odin.Logger 8 | import io.odin.slf4j.OdinLoggerBinder 9 | 10 | import java.util.concurrent.atomic.AtomicReference 11 | 12 | class OdinInterop extends OdinLoggerBinder[IO] { 13 | implicit val F: Sync[IO] = IO.asyncForIO 14 | 15 | implicit val dispatcher: Dispatcher[IO] = Dispatcher[IO].allocated.unsafeRunSync()._1 16 | 17 | val loggers: PartialFunction[String, Logger[IO]] = { 18 | val theLogger: String => Option[Logger[IO]] = _ => OdinInterop.globalLogger.get() 19 | 20 | theLogger.unlift 21 | } 22 | 23 | } 24 | 25 | object OdinInterop { 26 | val globalLogger: AtomicReference[Option[Logger[IO]]] = new AtomicReference[Option[Logger[IO]]](None) 27 | } 28 | -------------------------------------------------------------------------------- /core/src/main/scala/io/pg/background/background.scala: -------------------------------------------------------------------------------- 1 | package io.pg.background 2 | 3 | import io.pg.messaging._ 4 | 5 | sealed trait BackgroundProcess[F[_]] { 6 | 7 | def run: F[Unit] = 8 | this match { 9 | case BackgroundProcess.DrainStream(s, c) => s.compile(c).drain 10 | } 11 | 12 | } 13 | 14 | object BackgroundProcess { 15 | 16 | final case class DrainStream[F[_], G[_]]( 17 | stream: fs2.Stream[F, Nothing], 18 | C: fs2.Compiler[F, G] 19 | ) extends BackgroundProcess[G] 20 | 21 | def fromStream[F[_], G[_]]( 22 | stream: fs2.Stream[F, _] 23 | )( 24 | implicit C: fs2.Compiler[F, G] 25 | ): BackgroundProcess[G] = 26 | new DrainStream(stream.drain, C) 27 | 28 | def fromProcessor[F[_], A]( 29 | channel: Channel[F, A] 30 | )( 31 | processor: Processor[F, A] 32 | )( 33 | implicit C: fs2.Compiler[F, F] 34 | ): BackgroundProcess[F] = 35 | fromStream(channel.consume.through(processor.process)) 36 | 37 | } 38 | -------------------------------------------------------------------------------- /bootstrap/src/main/scala/org/polyvariant/Args.scala: -------------------------------------------------------------------------------- 1 | package org.polyvariant 2 | 3 | object Args { 4 | private val switch = "-(\\w+)".r 5 | private val option = "--(\\w+)".r 6 | 7 | private def parseNext(pendingArguments: List[String], previousResult: Map[String, String]): Map[String, String] = 8 | pendingArguments match { 9 | case Nil => previousResult 10 | case option(opt) :: value :: tail => parseNext(tail, previousResult ++ Map(opt -> value)) 11 | case switch(opt) :: tail => parseNext(tail, previousResult ++ Map(opt -> null)) 12 | case text :: Nil => previousResult ++ Map(text -> null) 13 | case text :: tail => parseNext(tail, previousResult ++ Map(text -> null)) 14 | } 15 | 16 | // TODO: Consider switching to https://ben.kirw.in/decline/ after https://github.com/bkirwi/decline/pull/293 17 | def parse(args: List[String]): Map[String, String] = 18 | parseNext(args.toList, Map()) 19 | 20 | } 21 | -------------------------------------------------------------------------------- /bootstrap/src/main/scala/org/polyvariant/Logger.scala: -------------------------------------------------------------------------------- 1 | package org.polyvariant 2 | 3 | import cats.syntax.apply 4 | import cats.effect.kernel.Sync 5 | import scala.io.AnsiColor._ 6 | 7 | trait Logger[F[_]] { 8 | def debug(msg: String): F[Unit] 9 | def success(msg: String): F[Unit] 10 | def info(msg: String): F[Unit] 11 | def warn(msg: String): F[Unit] 12 | def error(msg: String): F[Unit] 13 | } 14 | 15 | object Logger { 16 | def apply[F[_]](using ev: Logger[F]): Logger[F] = ev 17 | 18 | def wrappedPrint[F[_]: Sync] = new Logger[F] { 19 | private def colorPrinter(color: String)(msg: String): F[Unit] = 20 | Sync[F].delay(println(s"$color$msg$RESET")) 21 | 22 | override def debug(msg: String): F[Unit] = colorPrinter(CYAN)(msg) 23 | override def success(msg: String): F[Unit] = colorPrinter(GREEN)(msg) 24 | override def info(msg: String): F[Unit] = colorPrinter(WHITE)(msg) 25 | override def warn(msg: String): F[Unit] = colorPrinter(YELLOW)(msg) 26 | override def error(msg: String): F[Unit] = colorPrinter(RED)(msg) 27 | } 28 | 29 | } 30 | -------------------------------------------------------------------------------- /.scalafmt.conf: -------------------------------------------------------------------------------- 1 | runner.dialect=scala3 2 | 3 | version = "3.5.9" 4 | maxColumn = 140 5 | align.preset = some 6 | align.tokens."+" = [ 7 | {code = "<-", owner = Enumerator.Generator} 8 | ] 9 | align.multiline = true 10 | align.arrowEnumeratorGenerator = true 11 | 12 | newlines.topLevelStatements = [before, after] 13 | newlines.implicitParamListModifierForce = [before] 14 | newlines.topLevelStatementsMinBreaks = 2 15 | continuationIndent.defnSite = 2 16 | continuationIndent.extendSite = 2 17 | 18 | optIn.breakChainOnFirstMethodDot = true 19 | includeCurlyBraceInSelectChains = true 20 | includeNoParensInSelectChains = true 21 | 22 | rewrite.rules = [ 23 | RedundantBraces, 24 | RedundantParens, 25 | ExpandImportSelectors, 26 | PreferCurlyFors 27 | ] 28 | 29 | runner.optimizer.forceConfigStyleMinArgCount = 3 30 | danglingParentheses.defnSite = true 31 | danglingParentheses.callSite = true 32 | danglingParentheses.exclude = [ 33 | "`trait`" 34 | ] 35 | verticalMultiline.newlineAfterOpenParen = true 36 | verticalMultiline.atDefnSite = true 37 | 38 | fileOverride { 39 | "glob:**/bootstrap/src/main/scala/**" { 40 | runner.dialect = scala3 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /bootstrap/src/main/scala/org/polyvariant/Config.scala: -------------------------------------------------------------------------------- 1 | package org.polyvariant 2 | 3 | import cats.implicits.* 4 | import cats.MonadThrow 5 | import sttp.model.Uri 6 | import scala.util.Try 7 | 8 | final case class Config( 9 | gitlabUri: Uri, 10 | token: String, 11 | project: Long, 12 | botUser: String, 13 | pitgullWebhookUrl: Uri 14 | ) 15 | 16 | object Config { 17 | 18 | def fromArgs[F[_]: MonadThrow](args: Map[String, String]): F[Config] = 19 | MonadThrow[F] 20 | .catchNonFatal { 21 | Config( 22 | Uri.unsafeParse(args("url")), 23 | args("token"), 24 | args("project").toLong, 25 | args("bot"), 26 | Uri.unsafeParse(args("webhook")) 27 | ) 28 | } 29 | .recoverWith { case _ => 30 | MonadThrow[F].raiseError(ArgumentsParsingException) 31 | } 32 | 33 | val usage = """ 34 | |This program prepares your gitlab project for integration with Pitgull 35 | |by deleting existing Scala Steward mere requests and setting up 36 | |a webhook for triggering Pitgull. 37 | | 38 | |CLI Arguments: 39 | | --url - your gitlab url like https://gitlab.com/ 40 | | --token - your gitlab personal token, needs to have full access to project 41 | | --project - project ID, can be found on project main page 42 | | --bot - user name of Scala Steward bot user 43 | | --webhook - Pitgull target url like https://pitgull.example.com/webhook 44 | """.stripMargin 45 | 46 | case object ArgumentsParsingException extends Exception("Failed to parse CLI arguments") 47 | } 48 | -------------------------------------------------------------------------------- /src/test/scala/io/pg/fakes/ProjectConfigReaderFake.scala: -------------------------------------------------------------------------------- 1 | package io.pg.fakes 2 | 3 | import cats.MonadThrow 4 | import cats.effect.Ref 5 | import cats.implicits._ 6 | import cats.mtl.Stateful 7 | import io.pg.config.ProjectConfig 8 | import io.pg.config.ProjectConfigReader 9 | import io.pg.gitlab.webhook.Project 10 | import monocle.syntax.all._ 11 | 12 | trait FakeState 13 | 14 | object ProjectConfigReaderFake { 15 | 16 | sealed case class State( 17 | configs: Map[Long, ProjectConfig] 18 | ) 19 | 20 | object State { 21 | val initial: State = State(Map.empty) 22 | 23 | /** A collection of modifiers on the state, which will be provided together with the instance using it. 24 | */ 25 | trait Modifiers[F[_]] { 26 | def register(projectId: Long, config: ProjectConfig): F[Unit] 27 | } 28 | 29 | } 30 | 31 | type Data[F[_]] = Stateful[F, State] 32 | def Data[F[_]: Data]: Data[F] = implicitly[Data[F]] 33 | 34 | def refInstance[F[_]: Ref.Make: MonadThrow]: F[ProjectConfigReader[F] with State.Modifiers[F]] = 35 | Ref[F].of(State(Map.empty)).map(FakeUtils.statefulRef(_)).map(implicit F => instance[F]) 36 | 37 | def instance[F[_]: Data: MonadThrow]: ProjectConfigReader[F] with State.Modifiers[F] = 38 | new ProjectConfigReader[F] with State.Modifiers[F] { 39 | 40 | def readConfig(project: Project): F[ProjectConfig] = 41 | Data[F] 42 | .get 43 | .flatMap(_.configs.get(project.id).liftTo[F](new Throwable(s"Unknown project: $project"))) 44 | 45 | def register(projectId: Long, config: ProjectConfig): F[Unit] = 46 | Data[F].modify(_.focus(_.configs).modify(_ + (projectId -> config))) 47 | 48 | } 49 | 50 | } 51 | -------------------------------------------------------------------------------- /src/main/scala/io/pg/config/DiscriminatedCodec.scala: -------------------------------------------------------------------------------- 1 | package io.pg.config 2 | 3 | import io.circe.Codec 4 | import io.circe.Decoder 5 | import io.circe.DecodingFailure 6 | import io.circe.Json 7 | import scala.deriving.Mirror 8 | 9 | // Temporary replacement for https://github.com/circe/circe/pull/1800 10 | object DiscriminatedCodec { 11 | 12 | import scala.deriving._ 13 | import scala.compiletime._ 14 | 15 | private inline def deriveAll[T <: Tuple]: List[Codec.AsObject[_]] = inline erasedValue[T] match { 16 | case _: EmptyTuple => Nil 17 | case _: (h *: t) => 18 | Codec 19 | .AsObject 20 | .derived[h]( 21 | // feels odd but works 22 | using summonInline[Mirror.Of[h]] 23 | ) :: deriveAll[t] 24 | } 25 | 26 | inline def derived[A](discriminator: String)(using inline m: Mirror.SumOf[A]): Codec.AsObject[A] = { 27 | 28 | val codecs: List[Codec.AsObject[A]] = deriveAll[m.MirroredElemTypes].map(_.asInstanceOf[Codec.AsObject[A]]) 29 | 30 | val labels = 31 | summonAll[Tuple.Map[m.MirroredElemLabels, ValueOf]] 32 | .toList 33 | .asInstanceOf[List[ValueOf[String]]] 34 | .map(_.value) 35 | 36 | Codec 37 | .AsObject 38 | .from[A]( 39 | Decoder[String].at(discriminator).flatMap { key => 40 | val index = labels.indexOf(key) 41 | 42 | if (index < 0) Decoder.failedWithMessage(s"Unknown discriminator field $discriminator: $key") 43 | else codecs(index) 44 | }, 45 | value => { 46 | val index = m.ordinal(value) 47 | 48 | codecs(index) 49 | .mapJsonObject(_.add(discriminator, Json.fromString(labels(index)))) 50 | .encodeObject(value) 51 | } 52 | ) 53 | } 54 | 55 | } 56 | -------------------------------------------------------------------------------- /src/test/scala/io/pg/ProjectConfigFormatTests.scala: -------------------------------------------------------------------------------- 1 | package io.pg 2 | 3 | import weaver._ 4 | import io.circe.literal._ 5 | import io.pg.config.ProjectConfig 6 | import io.pg.config.ProjectConfigReader 7 | import io.pg.gitlab.webhook.Project 8 | import io.pg.config.Rule 9 | import io.pg.config.Action 10 | import io.pg.config.Matcher 11 | import io.pg.config.TextMatcher 12 | import io.circe.syntax._ 13 | 14 | object ProjectConfigFormatTest extends FunSuite { 15 | 16 | val asJSON = json"""{ 17 | "rules": [ 18 | { 19 | "action": "Merge", 20 | "matcher": { 21 | "kind": "Many", 22 | "values": [ 23 | { 24 | "email": { 25 | "kind": "Equals", 26 | "value": "scala.steward@ocado.com" 27 | }, 28 | "kind": "Author" 29 | }, 30 | { 31 | "kind": "Description", 32 | "text": { 33 | "kind": "Matches", 34 | "regex": ".*labels:.*semver-patch.*" 35 | } 36 | }, 37 | { 38 | "kind": "PipelineStatus", 39 | "status": "success" 40 | } 41 | ] 42 | }, 43 | "name": "Scala Steward" 44 | } 45 | ] 46 | } 47 | """ 48 | 49 | val decoded = ProjectConfig( 50 | rules = List( 51 | Rule( 52 | name = "Scala Steward", 53 | action = Action.Merge, 54 | matcher = Matcher.Many( 55 | List( 56 | Matcher.Author(TextMatcher.Equals("scala.steward@ocado.com")), 57 | Matcher.Description(TextMatcher.Matches(".*labels:.*semver-patch.*".r)), 58 | Matcher.PipelineStatus("success") 59 | ) 60 | ) 61 | ) 62 | ) 63 | ) 64 | 65 | test("Example config can be decoded") { 66 | val actual = asJSON.as[ProjectConfig] 67 | assert(actual == Right(decoded)) 68 | } 69 | test("Example config can be encoded") { 70 | val actual = decoded.asJson 71 | assert.eql(actual, asJSON) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/main/scala/io/pg/MergeRequests.scala: -------------------------------------------------------------------------------- 1 | package io.pg 2 | 3 | import cats.Applicative 4 | import cats.Monad 5 | import cats.Show 6 | import cats.data.EitherNel 7 | import cats.data.NonEmptyList 8 | import cats.implicits._ 9 | import fs2.Pipe 10 | import io.odin.Logger 11 | import io.pg.MergeRequestState 12 | import io.pg.ProjectActions 13 | import io.pg.ProjectActions.Mismatch 14 | import io.pg.StateResolver 15 | import io.pg.config.ProjectConfigReader 16 | import io.pg.gitlab.webhook.Project 17 | 18 | trait MergeRequests[F[_]] { 19 | def build(project: Project): F[List[MergeRequestState]] 20 | } 21 | 22 | object MergeRequests { 23 | def apply[F[_]](using F: MergeRequests[F]): MergeRequests[F] = F 24 | 25 | import scala.util.chaining._ 26 | 27 | def instance[F[_]: ProjectConfigReader: StateResolver: Monad: Logger]( 28 | implicit SC: fs2.Compiler[F, F] 29 | ): MergeRequests[F] = project => 30 | for { 31 | config <- ProjectConfigReader[F].readConfig(project) 32 | states <- StateResolver[F] 33 | .resolve(project) 34 | .flatMap(validActions[F, Mismatch, MergeRequestState, MergeRequestState](ProjectActions.compile(_, config))) 35 | } yield states 36 | 37 | private def validActions[F[_]: Logger: Applicative, E: Show, A, B]( 38 | compile: A => List[EitherNel[E, B]] 39 | )( 40 | states: List[A] 41 | )( 42 | implicit SC: fs2.Compiler[F, F] 43 | ): F[List[B]] = { 44 | def tapLeftAndDrop[L, R](log: L => F[Unit]): Pipe[F, Either[L, R], R] = 45 | _.evalTap(_.leftTraverse(log)).map(_.toOption).unNone 46 | 47 | val logMismatches: NonEmptyList[E] => F[Unit] = e => 48 | Logger[F].info( 49 | "Ignoring action because it didn't match rules", 50 | Map("rules" -> e.map(_.toString).mkString_(", ")) 51 | ) 52 | 53 | fs2 54 | .Stream 55 | .emits(states) 56 | .flatMap(compile(_).pipe(fs2.Stream.emits(_))) 57 | .through(tapLeftAndDrop(logMismatches)) 58 | .compile 59 | .toList 60 | } 61 | 62 | } 63 | -------------------------------------------------------------------------------- /core/src/main/scala/io/pg/messaging/messaging.scala: -------------------------------------------------------------------------------- 1 | package io.pg.messaging 2 | 3 | import cats.effect.std.Queue 4 | import scala.reflect.ClassTag 5 | import cats.syntax.all._ 6 | import cats.ApplicativeError 7 | import io.odin.Logger 8 | import cats.Functor 9 | import cats.Invariant 10 | import cats.ApplicativeThrow 11 | 12 | trait Publisher[F[_], -A] { 13 | def publish(a: A): F[Unit] 14 | } 15 | 16 | final case class Processor[F[_], -A](process: fs2.Pipe[F, A, Unit]) 17 | 18 | object Processor { 19 | 20 | def simple[F[_]: ApplicativeThrow: Logger, A]( 21 | f: A => F[Unit] 22 | ): Processor[F, A] = 23 | Processor[F, A] { 24 | _.evalMap { msg => 25 | f(msg).handleErrorWith(logError[F, A](msg)) 26 | } 27 | } 28 | 29 | def logError[F[_]: Logger, A](msg: A): Throwable => F[Unit] = 30 | e => 31 | Logger[F].error( 32 | "Encountered error while processing message", 33 | Map("message" -> msg.toString()), 34 | e 35 | ) 36 | 37 | } 38 | 39 | trait Channel[F[_], A] extends Publisher[F, A] { 40 | def consume: fs2.Stream[F, A] 41 | } 42 | 43 | object Channel { 44 | 45 | given [F[_]]: Invariant[Channel[F, *]] with { 46 | 47 | def imap[A, B](chan: Channel[F, A])(f: A => B)(g: B => A): Channel[F, B] = new { 48 | def consume: fs2.Stream[F, B] = chan.consume.map(f) 49 | def publish(b: B): F[Unit] = chan.publish(g(b)) 50 | } 51 | 52 | } 53 | 54 | def fromQueue[F[_]: Functor, A](q: Queue[F, A]): Channel[F, A] = 55 | new Channel[F, A] { 56 | def publish(a: A): F[Unit] = q.offer(a) 57 | val consume: fs2.Stream[F, A] = fs2.Stream.fromQueueUnterminated(q) 58 | } 59 | 60 | implicit class ChannelOpticsSyntax[F[_], A](val ch: Channel[F, A]) extends AnyVal { 61 | 62 | /** Transforms a channel into one that forwards everything to the publisher, but only consumes a subset of the original channel's 63 | * messages (the ones that match `f`). 64 | */ 65 | def prism[B](f: PartialFunction[A, B])(g: B => A): Channel[F, B] = 66 | new Channel[F, B] { 67 | def publish(a: B): F[Unit] = ch.publish(g(a)) 68 | val consume: fs2.Stream[F, B] = ch.consume.collect(f) 69 | } 70 | 71 | /** Limits a channel to a subtype of its message type. 72 | */ 73 | def only[B <: A: ClassTag]: Channel[F, B] = 74 | ch.prism { case b: B => 75 | b 76 | }(identity) 77 | 78 | } 79 | 80 | } 81 | -------------------------------------------------------------------------------- /src/main/scala/io/pg/config/format.scala: -------------------------------------------------------------------------------- 1 | package io.pg.config 2 | 3 | import cats.implicits._ 4 | import scala.util.matching.Regex 5 | import io.circe.Codec 6 | import io.circe.Decoder 7 | import io.circe.Encoder 8 | import io.circe.DecodingFailure 9 | import io.circe.Json 10 | 11 | object circe { 12 | 13 | private val decodeRegex: Decoder[Regex] = Decoder[String].flatMap { s => 14 | Either 15 | .catchNonFatal(s.r) 16 | .leftMap(DecodingFailure.fromThrowable(_, Nil)) 17 | .liftTo[Decoder] 18 | } 19 | 20 | private val encodeRegex: Encoder[Regex] = Encoder.encodeString.contramap[Regex](_.toString) 21 | 22 | implicit val regexCodec: Codec[Regex] = Codec.from(decodeRegex, encodeRegex) 23 | } 24 | 25 | import circe.regexCodec 26 | 27 | enum TextMatcher { 28 | case Equals(value: String) 29 | case Matches(regex: Regex) 30 | 31 | override def equals(another: Any) = (this, another) match { 32 | // Regex uses reference equality by default. 33 | // By using `.regex` we convert it back to a pattern string for better comparison. 34 | case (Matches(p1), Matches(p2)) => p1.regex == p2.regex 35 | case (Equals(e1), Equals(e2)) => e1 == e2 36 | case _ => false 37 | } 38 | 39 | } 40 | 41 | object TextMatcher { 42 | given Codec[TextMatcher] = DiscriminatedCodec.derived("kind") 43 | } 44 | 45 | enum Matcher { 46 | def and(another: Matcher): Matcher = Matcher.Many(List(this, another)) 47 | 48 | case Author(email: TextMatcher) 49 | case Description(text: TextMatcher) 50 | case PipelineStatus(status: String) 51 | case Many(values: List[Matcher]) 52 | case OneOf(values: List[Matcher]) 53 | case Not(underlying: Matcher) 54 | } 55 | 56 | object Matcher { 57 | given Codec[Matcher] = DiscriminatedCodec.derived("kind") 58 | } 59 | 60 | //todo: remove this type altogether and assume Merge for now? 61 | enum Action { 62 | case Merge 63 | } 64 | 65 | object Action { 66 | 67 | given Codec[Action] = Codec 68 | .from(Decoder[String], Encoder[String]) 69 | .iemap { 70 | case "Merge" => Action.Merge.asRight 71 | case s => ("Unknown action: " + s).asLeft 72 | }(_.toString) 73 | 74 | } 75 | 76 | final case class Rule(name: String, matcher: Matcher, action: Action) derives Codec.AsObject 77 | 78 | object Rule { 79 | val mergeAnything = Rule("anything", Matcher.Many(Nil), Action.Merge) 80 | } 81 | 82 | final case class ProjectConfig(rules: List[Rule]) derives Codec.AsObject 83 | 84 | object ProjectConfig { 85 | val empty = ProjectConfig(Nil) 86 | } 87 | -------------------------------------------------------------------------------- /.github/workflows/clean.yml: -------------------------------------------------------------------------------- 1 | # This file was automatically generated by sbt-github-actions using the 2 | # githubWorkflowGenerate task. You should add and commit this file to 3 | # your git repository. It goes without saying that you shouldn't edit 4 | # this file by hand! Instead, if you wish to make changes, you should 5 | # change your sbt build configuration to revise the workflow description 6 | # to meet your needs, then regenerate this file. 7 | 8 | name: Clean 9 | 10 | on: push 11 | 12 | jobs: 13 | delete-artifacts: 14 | name: Delete Artifacts 15 | runs-on: ubuntu-latest 16 | env: 17 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 18 | steps: 19 | - name: Delete artifacts 20 | run: | 21 | # Customize those three lines with your repository and credentials: 22 | REPO=${GITHUB_API_URL}/repos/${{ github.repository }} 23 | 24 | # A shortcut to call GitHub API. 25 | ghapi() { curl --silent --location --user _:$GITHUB_TOKEN "$@"; } 26 | 27 | # A temporary file which receives HTTP response headers. 28 | TMPFILE=/tmp/tmp.$$ 29 | 30 | # An associative array, key: artifact name, value: number of artifacts of that name. 31 | declare -A ARTCOUNT 32 | 33 | # Process all artifacts on this repository, loop on returned "pages". 34 | URL=$REPO/actions/artifacts 35 | while [[ -n "$URL" ]]; do 36 | 37 | # Get current page, get response headers in a temporary file. 38 | JSON=$(ghapi --dump-header $TMPFILE "$URL") 39 | 40 | # Get URL of next page. Will be empty if we are at the last page. 41 | URL=$(grep '^Link:' "$TMPFILE" | tr ',' '\n' | grep 'rel="next"' | head -1 | sed -e 's/.*.*//') 42 | rm -f $TMPFILE 43 | 44 | # Number of artifacts on this page: 45 | COUNT=$(( $(jq <<<$JSON -r '.artifacts | length') )) 46 | 47 | # Loop on all artifacts on this page. 48 | for ((i=0; $i < $COUNT; i++)); do 49 | 50 | # Get name of artifact and count instances of this name. 51 | name=$(jq <<<$JSON -r ".artifacts[$i].name?") 52 | ARTCOUNT[$name]=$(( $(( ${ARTCOUNT[$name]} )) + 1)) 53 | 54 | id=$(jq <<<$JSON -r ".artifacts[$i].id?") 55 | size=$(( $(jq <<<$JSON -r ".artifacts[$i].size_in_bytes?") )) 56 | printf "Deleting '%s' #%d, %'d bytes\n" $name ${ARTCOUNT[$name]} $size 57 | ghapi -X DELETE $REPO/actions/artifacts/$id 58 | done 59 | done 60 | -------------------------------------------------------------------------------- /src/main/scala/io/pg/appconfig.scala: -------------------------------------------------------------------------------- 1 | package io.pg 2 | 3 | import cats.syntax.all._ 4 | import ciris.Secret 5 | import org.http4s.Headers 6 | import sttp.model.Uri 7 | import org.typelevel.ci.CIString 8 | 9 | final case class AppConfig( 10 | http: HttpConfig, 11 | meta: MetaConfig, 12 | git: Git, 13 | queues: Queues, 14 | middleware: MiddlewareConfig 15 | ) 16 | 17 | object AppConfig { 18 | 19 | private val bannerString = 20 | """| _ _ _ _ 21 | | (_) | | | | 22 | | _ __ _| |_ __ _ _ _| | | 23 | | | '_ \| | __/ _` | | | | | | 24 | | | |_) | | || (_| | |_| | | | 25 | | | .__/|_|\__\__, |\__,_|_|_| 26 | | | | __/ | 27 | | |_| |___/ 28 | | 29 | |""".stripMargin 30 | 31 | import ciris._ 32 | 33 | val httpConfig: ConfigValue[ciris.Effect, HttpConfig] = 34 | env("HTTP_PORT").as[Int].default(8080).map(HttpConfig(_)) 35 | 36 | val metaConfig: ConfigValue[ciris.Effect, MetaConfig] = 37 | ( 38 | default(bannerString), 39 | default(BuildInfo.version), 40 | default(BuildInfo.scalaVersion) 41 | ).parMapN(MetaConfig.apply) 42 | 43 | implicit val decodeUri: ConfigDecoder[String, Uri] = 44 | ConfigDecoder[String, String].mapEither { (key, value) => 45 | Uri 46 | .parse(value) 47 | .leftMap(e => ConfigError(s"Invalid URI ($value at $key), error: $e")) 48 | } 49 | 50 | val gitConfig: ConfigValue[ciris.Effect, Git] = 51 | ( 52 | default(Git.Host.Gitlab), 53 | env("GIT_API_URL").as[Uri], 54 | env("GIT_API_TOKEN").secret 55 | ).parMapN(Git.apply) 56 | 57 | private val queuesConfig: ConfigValue[ciris.Effect, Queues] = default(100).map(Queues.apply) 58 | 59 | private val middlewareConfig: ConfigValue[ciris.Effect, MiddlewareConfig] = 60 | default(Headers.SensitiveHeaders + CIString("Private-Token")).map(MiddlewareConfig.apply) 61 | 62 | val appConfig: ConfigValue[ciris.Effect, AppConfig] = 63 | (httpConfig, metaConfig, gitConfig, queuesConfig, middlewareConfig).parMapN(apply) 64 | 65 | } 66 | 67 | final case class HttpConfig(port: Int) 68 | 69 | final case class MetaConfig( 70 | banner: String, 71 | version: String, 72 | scalaVersion: String 73 | ) 74 | 75 | final case class Git(host: Git.Host, apiUrl: Uri, apiToken: Secret[String]) 76 | 77 | object Git { 78 | sealed trait Host extends Product with Serializable 79 | 80 | object Host { 81 | case object Gitlab extends Host 82 | } 83 | 84 | } 85 | 86 | final case class Queues(maxSize: Int) 87 | 88 | final case class MiddlewareConfig(sensitiveHeaders: Set[CIString]) 89 | -------------------------------------------------------------------------------- /src/main/scala/io/pg/Application.scala: -------------------------------------------------------------------------------- 1 | package io.pg 2 | 3 | import cats.data.NonEmptyList 4 | import cats.effect.Resource 5 | import cats.effect.kernel.Async 6 | import cats.effect.std.Queue 7 | import cats.effect.implicits._ 8 | import cats.syntax.all._ 9 | import io.odin.Logger 10 | import io.pg.background.BackgroundProcess 11 | import io.pg.config.ProjectConfigReader 12 | import io.pg.gitlab.Gitlab 13 | import io.pg.gitlab.webhook.WebhookEvent 14 | import io.pg.messaging._ 15 | import io.pg.webhook._ 16 | import org.http4s.HttpApp 17 | import org.http4s.blaze.client.BlazeClientBuilder 18 | import org.http4s.implicits._ 19 | import sttp.capabilities.fs2.Fs2Streams 20 | import sttp.client3.SttpBackend 21 | import sttp.client3.http4s.Http4sBackend 22 | 23 | sealed trait Event extends Product with Serializable 24 | 25 | object Event { 26 | final case class Webhook(value: WebhookEvent) extends Event 27 | } 28 | 29 | final class Application[F[_]]( 30 | val routes: HttpApp[F], 31 | val background: NonEmptyList[BackgroundProcess[F]] 32 | ) 33 | 34 | object Application { 35 | 36 | def resource[F[_]: Logger: Async]( 37 | config: AppConfig 38 | ): Resource[F, Application[F]] = { 39 | given ProjectConfigReader[F] = ProjectConfigReader.test[F] 40 | 41 | Queue 42 | .bounded[F, Event](config.queues.maxSize) 43 | .map(Channel.fromQueue(_)) 44 | .toResource 45 | .flatMap { eventChannel => 46 | implicit val webhookChannel: Channel[F, WebhookEvent] = 47 | eventChannel.only[Event.Webhook].imap(_.value)(Event.Webhook.apply) 48 | 49 | BlazeClientBuilder[F] 50 | .resource 51 | .map( 52 | org 53 | .http4s 54 | .client 55 | .middleware 56 | .Logger(logHeaders = true, logBody = false, redactHeadersWhen = config.middleware.sensitiveHeaders.contains) 57 | ) 58 | .map { client => 59 | implicit val backend: SttpBackend[F, Fs2Streams[F]] = 60 | Http4sBackend.usingClient[F](client) 61 | 62 | implicit val gitlab: Gitlab[F] = 63 | Gitlab.sttpInstance[F](config.git.apiUrl, config.git.apiToken) 64 | 65 | implicit val projectActions: ProjectActions[F] = 66 | ProjectActions.instance[F] 67 | 68 | implicit val stateResolver: StateResolver[F] = 69 | StateResolver.instance[F] 70 | 71 | implicit val mergeRequests: MergeRequests[F] = 72 | MergeRequests.instance[F] 73 | 74 | val webhookProcess = BackgroundProcess.fromProcessor( 75 | webhookChannel 76 | )(Processor.simple(WebhookProcessor.instance[F])) 77 | 78 | new Application[F]( 79 | routes = WebhookRouter.routes[F].orNotFound, 80 | background = NonEmptyList.one(webhookProcess) 81 | ) 82 | } 83 | } 84 | } 85 | 86 | } 87 | -------------------------------------------------------------------------------- /src/main/scala/io/pg/webhook/webhook.scala: -------------------------------------------------------------------------------- 1 | package io.pg.webhook 2 | 3 | import cats.Monad 4 | import cats.MonadError 5 | import cats.implicits._ 6 | import io.odin.Logger 7 | import io.pg.MergeRequests 8 | import io.pg.ProjectActions 9 | import io.pg.gitlab.webhook.Project 10 | import io.pg.gitlab.webhook.WebhookEvent 11 | import io.pg.messaging.Publisher 12 | import io.pg.transport 13 | import org.http4s.HttpRoutes 14 | import org.http4s.circe.CirceEntityCodec._ 15 | import org.http4s.circe._ 16 | import org.http4s.dsl.Http4sDsl 17 | import cats.MonadThrow 18 | import io.pg.gitlab.Gitlab.MergeRequestInfo 19 | import io.pg.MergeRequestState.Mergeability 20 | import io.pg.MergeRequestState 21 | 22 | object WebhookRouter { 23 | 24 | def routes[F[_]: MergeRequests: JsonDecoder: Monad]( 25 | implicit eventPublisher: Publisher[F, WebhookEvent] 26 | ): HttpRoutes[F] = { 27 | val dsl = new Http4sDsl[F] {} 28 | import dsl._ 29 | 30 | HttpRoutes.of[F] { 31 | case req @ POST -> Root / "webhook" => 32 | req.asJsonDecode[WebhookEvent].flatMap(eventPublisher.publish) *> Ok() 33 | 34 | case GET -> Root / "preview" / LongVar(projectId) => 35 | val proj = Project(projectId) 36 | 37 | MergeRequests[F] 38 | .build(proj) 39 | .nested 40 | .map(mergeRequestToTransport) 41 | .value 42 | .flatMap(Ok(_)) 43 | } 44 | 45 | } 46 | 47 | private def mergeRequestToTransport(mr: MergeRequestState): io.pg.transport.MergeRequestState = transport.MergeRequestState( 48 | projectId = mr.projectId, 49 | mergeRequestIid = mr.mergeRequestIid, 50 | description = mr.description, 51 | status = mr.status match { 52 | case MergeRequestInfo.Status.Success => transport.MergeRequestState.Status.Success 53 | case MergeRequestInfo.Status.Other(s) => transport.MergeRequestState.Status.Other(s) 54 | }, 55 | mergeability = mr.mergeability match { 56 | case Mergeability.CanMerge => transport.MergeRequestState.Mergeability.CanMerge 57 | case Mergeability.HasConflicts => transport.MergeRequestState.Mergeability.HasConflicts 58 | case Mergeability.NeedsRebase => transport.MergeRequestState.Mergeability.NeedsRebase 59 | }, 60 | authorUsername = mr.authorUsername 61 | ) 62 | 63 | } 64 | 65 | object WebhookProcessor { 66 | 67 | def instance[ 68 | F[ 69 | _ 70 | ]: MergeRequests: ProjectActions: Logger: MonadThrow 71 | ]: WebhookEvent => F[Unit] = { ev => 72 | for { 73 | _ <- Logger[F].info("Received event", Map("event" -> ev.toString())) 74 | states <- MergeRequests[F].build(ev.project) 75 | 76 | nextMR = states.minByOption(_.mergeability) 77 | _ <- Logger[F].info("Considering MR for action", Map("mr" -> nextMR.show)) 78 | action <- nextMR.flatTraverse(ProjectActions[F].resolve(_)) 79 | _ <- action.traverse(ProjectActions[F].execute(_)) 80 | } yield () 81 | } 82 | 83 | } 84 | -------------------------------------------------------------------------------- /src/main/scala/io/pg/resolver.scala: -------------------------------------------------------------------------------- 1 | package io.pg 2 | 3 | import cats.MonadError 4 | import cats.implicits._ 5 | import cats.kernel.Order 6 | import io.odin.Logger 7 | import io.pg.gitlab.Gitlab 8 | import io.pg.gitlab.Gitlab.MergeRequestInfo 9 | import io.pg.gitlab.webhook.Project 10 | import cats.Show 11 | import cats.MonadThrow 12 | import monocle.syntax.all._ 13 | 14 | trait StateResolver[F[_]] { 15 | def resolve(project: Project): F[List[MergeRequestState]] 16 | } 17 | 18 | object StateResolver { 19 | def apply[F[_]](using F: StateResolver[F]): StateResolver[F] = F 20 | 21 | def instance[F[_]: Gitlab: Logger: MonadThrow]( 22 | implicit SC: fs2.Compiler[F, F] 23 | ): StateResolver[F] = 24 | new StateResolver[F] { 25 | 26 | private def findMergeRequests(project: Project): F[List[MergeRequestState]] = 27 | Gitlab[F] 28 | .mergeRequests(projectId = project.id) 29 | .nested 30 | .map(buildState) 31 | .value 32 | 33 | private def buildState( 34 | mr: MergeRequestInfo 35 | ): MergeRequestState = 36 | MergeRequestState( 37 | projectId = mr.projectId, 38 | mergeRequestIid = mr.mergeRequestIid, 39 | authorUsername = mr.authorUsername, 40 | description = mr.description, 41 | status = mr.status.getOrElse(MergeRequestInfo.Status.Success), 42 | mergeability = MergeRequestState 43 | .Mergeability 44 | .fromFlags( 45 | hasConflicts = mr.hasConflicts, 46 | needsRebase = mr.needsRebase 47 | ) 48 | ) 49 | 50 | def resolve(project: Project): F[List[MergeRequestState]] = 51 | findMergeRequests(project) 52 | .flatTap { state => 53 | Logger[F].info("Resolved MR state", Map("state" -> state.show)) 54 | } 55 | 56 | } 57 | 58 | } 59 | 60 | //current MR state - rebuilt on every event. 61 | //Checked against rules to come up with a decision. 62 | final case class MergeRequestState( 63 | projectId: Long, 64 | mergeRequestIid: Long, 65 | authorUsername: String, 66 | description: Option[String], 67 | status: MergeRequestInfo.Status, 68 | mergeability: MergeRequestState.Mergeability 69 | ) 70 | 71 | object MergeRequestState { 72 | sealed trait Mergeability extends Product with Serializable 73 | 74 | object Mergeability { 75 | case object CanMerge extends Mergeability 76 | case object NeedsRebase extends Mergeability 77 | case object HasConflicts extends Mergeability 78 | 79 | def fromFlags(hasConflicts: Boolean, needsRebase: Boolean): Mergeability = 80 | if (hasConflicts) HasConflicts 81 | else if (needsRebase) NeedsRebase 82 | else CanMerge 83 | 84 | implicit val order: Order[Mergeability] = Order.by(List(CanMerge, NeedsRebase, HasConflicts).indexOf) 85 | } 86 | 87 | implicit val showTrimmed: Show[MergeRequestState] = 88 | _.focus(_.description).modify(_.map(TextUtils.trim(maxChars = 80))).toString 89 | } 90 | -------------------------------------------------------------------------------- /src/main/scala/io/pg/Main.scala: -------------------------------------------------------------------------------- 1 | package io.pg 2 | 3 | import cats.effect.ExitCode 4 | import cats.effect.IO 5 | import cats.effect.IOApp 6 | import cats.effect.Resource 7 | import cats.effect.Sync 8 | import cats.effect.implicits._ 9 | import cats.effect.kernel.Async 10 | import cats.syntax.all._ 11 | import cats.~> 12 | import io.chrisdavenport.cats.time.instances.all._ 13 | import io.odin.Level 14 | import io.odin.Logger 15 | import io.odin.formatter.Formatter 16 | import org.http4s.HttpApp 17 | import org.http4s.blaze.server.BlazeServerBuilder 18 | import org.http4s.server.middleware 19 | 20 | import scala.concurrent.duration._ 21 | import cats.arrow.FunctionK 22 | 23 | object Main extends IOApp { 24 | 25 | def mkLogger[F[_]: Async](fToIO: F ~> IO): Resource[F, Logger[F]] = { 26 | 27 | val console = io.odin.consoleLogger[F](formatter = Formatter.colorful).withMinimalLevel(Level.Info).pure[Resource[F, *]] 28 | 29 | val file = io 30 | .odin 31 | .asyncRollingFileLogger[F]( 32 | fileNamePattern = dateTime => show"/tmp/log/pitgull/pitgull-logs-${dateTime.toLocalDate}.txt", 33 | rolloverInterval = 1.day.some, 34 | maxFileSizeInBytes = (10L * 1024 * 1024 /* 10MB */ ).some, 35 | maxBufferSize = 10.some, 36 | formatter = Formatter.colorful, 37 | minLevel = Level.Debug 38 | ) 39 | 40 | console |+| file 41 | } 42 | .evalTap { logger => 43 | Sync[F].delay( 44 | OdinInterop 45 | .globalLogger 46 | .set( 47 | logger 48 | .mapK(fToIO) 49 | .some 50 | ) 51 | ) 52 | } 53 | 54 | def mkServer[F[_]: Logger: Async]( 55 | config: AppConfig, 56 | routes: HttpApp[F] 57 | ) = { 58 | val app = middleware 59 | .Logger 60 | .httpApp( 61 | logHeaders = true, 62 | logBody = true, 63 | logAction = ((msg: String) => Logger[F].debug(msg)).some 64 | )(routes) 65 | 66 | BlazeServerBuilder[F] 67 | .withHttpApp(app) 68 | .bindHttp(port = config.http.port, host = "0.0.0.0") 69 | .withBanner(config.meta.banner.linesIterator.toList) 70 | .resource 71 | } 72 | 73 | def logStarting[F[_]: Logger](meta: MetaConfig) = 74 | Logger[F].info("Starting application", Map("version" -> meta.version, "scalaVersion" -> meta.scalaVersion)) 75 | 76 | def logStarted[F[_]: Logger](meta: MetaConfig) = 77 | Logger[F].info("Started application", Map("version" -> meta.version, "scalaVersion" -> meta.scalaVersion)) 78 | 79 | def serve[F[_]: Async](fToIO: F ~> IO)(config: AppConfig) = 80 | for { 81 | given Logger[F] <- mkLogger[F](fToIO) 82 | _ <- logStarting(config.meta).toResource 83 | resources <- Application.resource[F](config) 84 | _ <- mkServer[F](config, resources.routes) 85 | _ <- resources.background.parTraverse_(_.run).background 86 | _ <- logStarted(config.meta).toResource 87 | } yield () 88 | 89 | def run(args: List[String]): IO[ExitCode] = 90 | AppConfig 91 | .appConfig 92 | .resource[IO] 93 | .flatMap(serve[IO](FunctionK.id)) 94 | .useForever 95 | 96 | } 97 | -------------------------------------------------------------------------------- /src/main/scala/io/pg/config/ProjectConfig.scala: -------------------------------------------------------------------------------- 1 | package io.pg.config 2 | 3 | import cats.Applicative 4 | import cats.MonadThrow 5 | import cats.effect.ExitCode 6 | import cats.syntax.all._ 7 | import io.github.vigoo.prox.ProxFS2 8 | import io.pg.gitlab.webhook.Project 9 | 10 | import java.nio.file.Paths 11 | import scala.util.chaining._ 12 | 13 | trait ProjectConfigReader[F[_]] { 14 | def readConfig(project: Project): F[ProjectConfig] 15 | } 16 | 17 | object ProjectConfigReader { 18 | def apply[F[_]](using F: ProjectConfigReader[F]): ProjectConfigReader[F] = F 19 | 20 | def test[F[_]: Applicative]: ProjectConfigReader[F] = 21 | new ProjectConfigReader[F] { 22 | 23 | def semver(level: String) = Matcher.Description(TextMatcher.Matches(s"(?s).*labels:.*semver-$level.*".r)) 24 | 25 | // todo: dhall needs to be updated 26 | def steward(extra: Matcher) = Rule( 27 | "scala-steward", 28 | Matcher.Many( 29 | List( 30 | Matcher.Author(TextMatcher.Matches("(scala_steward)|(michal.pawlik)|(j.kozlowski)".r)), 31 | Matcher.PipelineStatus("SUCCESS"), 32 | extra 33 | ) 34 | ), 35 | Action.Merge 36 | ) 37 | 38 | val anyLibraryPatch = steward(semver("patch")) 39 | 40 | val fromWMS = Matcher.Description(TextMatcher.Matches("""(?s).*((com\.ocado\.ospnow\.wms)|(com\.ocado\.gm\.wms))(?s).*""".r)) 41 | 42 | val wmsLibraryMinor = steward( 43 | semver("minor").and(fromWMS) 44 | ) 45 | 46 | val config: ProjectConfig = ProjectConfig( 47 | List( 48 | anyLibraryPatch, 49 | wmsLibraryMinor 50 | ) 51 | ) 52 | 53 | def readConfig(project: Project): F[ProjectConfig] = config.pure[F] 54 | } 55 | 56 | def dhallJsonStringConfig[F[_]: ProxFS2: MonadThrow]: F[ProjectConfigReader[F]] = { 57 | val prox: ProxFS2[F] = implicitly 58 | import prox._ 59 | 60 | val dhallCommand = "dhall-to-json" 61 | // todo: not reading a local file 62 | val filePath = "./example.dhall" 63 | 64 | def checkExitCode[O, E]: F[ProcessResult[O, E]] => F[ProcessResult[O, E]] = 65 | _.ensure(new Throwable("Invalid exit code"))( 66 | _.exitCode == ExitCode.Success 67 | ) 68 | 69 | implicit val runner: ProcessRunner[JVMProcessInfo] = new JVMProcessRunner 70 | 71 | val instance: ProjectConfigReader[F] = new ProjectConfigReader[F] { 72 | 73 | def readConfig(project: Project): F[ProjectConfig] = 74 | Process(dhallCommand) 75 | .`with`("TOKEN" -> "demo-token") 76 | .fromFile(Paths.get(filePath)) 77 | .toFoldMonoid(fs2.text.utf8.decode[F]) 78 | .run() 79 | .pipe(checkExitCode) 80 | .map(_.output) 81 | .flatMap(io.circe.parser.decode[ProjectConfig](_).liftTo[F]) 82 | } 83 | 84 | val ensureCommandExists = 85 | Process("bash", "-c" :: s"command -v $dhallCommand" :: Nil) 86 | .drainOutput(_.drain) 87 | .run() 88 | .pipe(checkExitCode) 89 | .adaptError { case e => 90 | new Throwable(s"Command $dhallCommand not found", e) 91 | } 92 | 93 | ensureCommandExists.as(instance) 94 | } 95 | 96 | } 97 | -------------------------------------------------------------------------------- /bootstrap/src/main/scala/org/polyvariant/Main.scala: -------------------------------------------------------------------------------- 1 | package org.polyvariant 2 | 3 | import cats.implicits.* 4 | import cats.effect.* 5 | 6 | import sttp.model.Uri 7 | 8 | import sttp.client3.* 9 | import org.polyvariant.Gitlab.MergeRequestInfo 10 | import cats.Applicative 11 | import sttp.monad.MonadError 12 | import cats.MonadThrow 13 | import org.polyvariant.Config.ArgumentsParsingException 14 | import cats.effect.std.Console 15 | import cats.Monad 16 | 17 | object Main extends IOApp { 18 | 19 | private def printMergeRequests[F[_]: Logger: Applicative](mergeRequests: List[MergeRequestInfo]): F[Unit] = 20 | mergeRequests.traverse { mr => 21 | Logger[F].info(s"ID: ${mr.mergeRequestIid} by: ${mr.authorUsername}") 22 | }.void 23 | 24 | private def readConsent[F[_]: Console: MonadThrow]: F[Unit] = 25 | MonadThrow[F] 26 | .ifM(Console[F].readLine.map(_.trim.toLowerCase == "y"))( 27 | ifTrue = MonadThrow[F].pure(()), 28 | ifFalse = MonadThrow[F].raiseError(new Exception("User rejected deletion")) 29 | ) 30 | 31 | private def qualifyMergeRequestsForDeletion(botUserName: String, mergeRequests: List[MergeRequestInfo]): List[MergeRequestInfo] = 32 | mergeRequests.filter(_.authorUsername == botUserName) 33 | 34 | private def deleteMergeRequests[F[_]: Gitlab: Logger: Applicative](project: Long, mergeRequests: List[MergeRequestInfo]): F[Unit] = 35 | mergeRequests.traverse(mr => Gitlab[F].deleteMergeRequest(project, mr.mergeRequestIid)).void 36 | 37 | private def createWebhook[F[_]: Gitlab: Logger: Applicative](project: Long, webhook: Uri): F[Unit] = 38 | Logger[F].info("Creating webhook") *> 39 | Gitlab[F].createWebhook(project, webhook) *> 40 | Logger[F].info("Webhook created") 41 | 42 | private def configureWebhooks[F[_]: Gitlab: Logger: Monad](project: Long, webhook: Uri): F[Unit] = for { 43 | hooks <- Gitlab[F].listWebhooks(project).map(_.filter(_.url == webhook.toString)) 44 | _ <- Monad[F] 45 | .ifM(hooks.nonEmpty.pure[F])( 46 | ifTrue = Logger[F].success("Webhook already exists"), 47 | ifFalse = createWebhook(project, webhook) 48 | ) 49 | } yield () 50 | 51 | private def program[F[_]: Logger: Console: Async](args: List[String]): F[Unit] = { 52 | given SttpBackend[Identity, Any] = HttpURLConnectionBackend() 53 | val parsedArgs = Args.parse(args) 54 | for { 55 | config <- Config.fromArgs(parsedArgs) 56 | _ <- Logger[F].info("Starting pitgull bootstrap!") 57 | given Gitlab[F] = Gitlab.sttpInstance[F](config.gitlabUri, config.token) 58 | mrs <- Gitlab[F].mergeRequests(config.project) 59 | _ <- Logger[F].info(s"Merge requests found: ${mrs.length}") 60 | _ <- printMergeRequests(mrs) 61 | botMrs = qualifyMergeRequestsForDeletion(config.botUser, mrs) 62 | _ <- Logger[F].info(s"Will delete merge requests: ${botMrs.map(_.mergeRequestIid).mkString(", ")}") 63 | _ <- Logger[F].info("Do you want to proceed? y/Y") 64 | _ <- readConsent 65 | _ <- deleteMergeRequests(config.project, botMrs) 66 | _ <- Logger[F].info("Done processing merge requests") 67 | _ <- Logger[F].info("Configuring webhook") 68 | _ <- configureWebhooks(config.project, config.pitgullWebhookUrl) 69 | _ <- Logger[F].success("Bootstrap finished") 70 | } yield () 71 | } 72 | 73 | override def run(args: List[String]): IO[ExitCode] = { 74 | given Logger[IO] = Logger.wrappedPrint[IO] 75 | program[IO](args).recoverWith { 76 | case Config.ArgumentsParsingException => 77 | Logger[IO].info(Config.usage) 78 | case e: Exception => 79 | Logger[IO].error(s"Unexpected error ocurred: $e") 80 | } *> 81 | IO.pure(ExitCode.Success) 82 | } 83 | 84 | } 85 | -------------------------------------------------------------------------------- /dhall/core.dhall: -------------------------------------------------------------------------------- 1 | let List/map = 2 | https://prelude.dhall-lang.org/v16.0.0/List/map sha256:dd845ffb4568d40327f2a817eb42d1c6138b929ca758d50bc33112ef3c885680 3 | 4 | let TextMatcherFold = 5 | λ(Result : Type) → 6 | { Equals : { value : Text } → Result 7 | , Matches : { regex : Text } → Result 8 | } 9 | 10 | let TextMatcher 11 | : Type 12 | = ∀(Result : Type) → TextMatcherFold Result → Result 13 | 14 | let text = 15 | { Equals = 16 | λ(args : { value : Text }) → 17 | λ(Result : Type) → 18 | λ(Fold : TextMatcherFold Result) → 19 | Fold.Equals { value = args.value } 20 | , Matches = 21 | λ(args : { regex : Text }) → 22 | λ(Result : Type) → 23 | λ(Fold : TextMatcherFold Result) → 24 | Fold.Matches { regex = args.regex } 25 | } 26 | : { Equals : { value : Text } → TextMatcher 27 | , Matches : { regex : Text } → TextMatcher 28 | } 29 | 30 | let MatcherFold = 31 | λ(M : Type) → 32 | { Author : { email : TextMatcher } → M 33 | , Description : { text : TextMatcher } → M 34 | , PipelineStatus : { status : Text } → M 35 | , Many : List M → M 36 | , OneOf : List M → M 37 | , Not : M → M 38 | } 39 | 40 | let Matcher 41 | : Type 42 | = ∀(M : Type) → MatcherFold M → M 43 | 44 | let listOf = 45 | λ(elems : List Matcher) → 46 | λ(M : Type) → 47 | λ(path : MatcherFold M → List M → M) → 48 | λ(RC : MatcherFold M) → 49 | let foldChild 50 | : Matcher → M 51 | = λ(y : Matcher) → y M RC 52 | 53 | let folded = List/map Matcher M foldChild elems 54 | 55 | in path RC folded 56 | 57 | let match = 58 | { Author = 59 | λ(args : { email : TextMatcher }) → 60 | λ(M : Type) → 61 | λ(RC : MatcherFold M) → 62 | RC.Author { email = args.email } 63 | , Description = 64 | λ(args : { text : TextMatcher }) → 65 | λ(M : Type) → 66 | λ(RC : MatcherFold M) → 67 | RC.Description { text = args.text } 68 | , PipelineStatus = 69 | λ(status : Text) → 70 | λ(M : Type) → 71 | λ(RC : MatcherFold M) → 72 | RC.PipelineStatus { status } 73 | , Many = 74 | λ(elems : List Matcher) → 75 | λ(M : Type) → 76 | listOf elems M (λ(RC : MatcherFold M) → RC.Many) 77 | , OneOf = 78 | λ(elems : List Matcher) → 79 | λ(M : Type) → 80 | listOf elems M (λ(RC : MatcherFold M) → RC.OneOf) 81 | , Not = 82 | λ(elem : Matcher) → 83 | λ(M : Type) → 84 | λ(RC : MatcherFold M) → 85 | RC.Not (elem M RC) 86 | } 87 | : { Author : { email : TextMatcher } → Matcher 88 | , Description : { text : TextMatcher } → Matcher 89 | , PipelineStatus : Text → Matcher 90 | , Many : List Matcher → Matcher 91 | , OneOf : List Matcher → Matcher 92 | , Not : Matcher → Matcher 93 | } 94 | 95 | let ActionFold = λ(M : Type) → { Merge : M } 96 | 97 | let Action = ∀(M : Type) → ActionFold M → M 98 | 99 | let action = 100 | { Merge = λ(M : Type) → λ(AF : ActionFold M) → AF.Merge } 101 | : { Merge : Action } 102 | 103 | let Rule = { name : Text, matcher : Matcher, action : Action } 104 | 105 | let ProjectConfig = { rules : List Rule } 106 | 107 | in { TextMatcherFold 108 | , TextMatcher 109 | , text 110 | , MatcherFold 111 | , Matcher 112 | , match 113 | , Action 114 | , action 115 | , Rule 116 | , ProjectConfig 117 | } 118 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Pitgull 2 | 3 | [![License](https://img.shields.io/:license-Apache%202-green.svg)](http://www.apache.org/licenses/LICENSE-2.0.txt) 4 | ![Continuous Integration](https://github.com/pitgull/pitgull/workflows/Continuous%20Integration/badge.svg) 5 | [![Powered by cats](https://img.shields.io/badge/powered%20by-cats-blue.svg)](https://github.com/typelevel/cats) 6 | ![Gluten free](https://img.shields.io/badge/gluten-free-orange.svg) 7 | 8 | ## How it works 9 | 10 | The core idea behind the project is very simple - create a tool for automatically applying merge requests created by [Scala Steward](https://github.com/scala-steward-org/scala-steward) on any [Gitlab](https://gitlab.com/) instance. Currently Pitgull is ortogonal from the bots, the merge requests are qualified by generic rules. 11 | 12 | Pitgull works as a web service, listening for webhook events from Gitlab instance. Upon receiving a webhook, it reads related project's merge requests, finds one best candidate and tries to merge it. If all qualifying merge requests need to be updated, an attempt to rebase them onto the target branch will be made. 13 | Once an MR is merged, Gitlab will trigger another webhook, creating a loop until Pitgull finds no qualifying merge request. 14 | 15 | ![Flow diagram](https://www.plantuml.com/plantuml/svg/VOz1IyD048Nl-HNZtT9x3wM288Ar9HVFkzcNPEncDyxEjkJV6sqDXGIlm_UzzsQNr8ZcpXSFsg83MP_HY1cA41KKpn1wOVN6RkZ8FJm7hFUG1YJuoaYwVly1SKPGua2zn4zKMbobrVR8scJlD_G1syPuAcw7rVOlzd2wwvhmLuUWN0zJu09JmZZg80s7XYHx9AgZJCQiwOsJKdS_VXAMD-zdx7zp3k8Wj2yJsU5QOonxrk6H4lmeKSsIT2IMx6TKx42NrYXf91VfmjhUJBZHcZzKmfg4zLDLeV_DtI6utFbl) 16 | 17 | 18 | ## Integrating with Pitgull 19 | 20 | Along with Pitgull, we provide a `pitgull-bootstrap` command line utility. This program prepares your GitLab project for integration with Pitgull by deleting existing Scala Steward merge requests and setting up a webhook for triggering Pitgull. 21 | ``` 22 | CLI Arguments: 23 | --url - your gitlab url like https://gitlab.com/ 24 | --token - your gitlab personal token, needs to have full access to project 25 | --project - project ID, can be found on project main page 26 | --bot - user name of Scala Steward bot user 27 | --webhook - Pitgull target url like https://pitgull.example.com/webhook 28 | ``` 29 | ### Why delete existing merge requests? 30 | 31 | Pitgull will only take action when it's triggered by a webhook. By deleting merge requests we make sure no Scala Steward MR gets unnoticed. If we'd only close them, Scala Steward wouldn't update them, so no webhook would be triggerd. 32 | 33 | Additionally, if you have some legacy merge requests for single library, this program makes sure to clean them up. When Scala Steward notices that some dependency is out of date and MR is missing - it will recreate it, so no worries about skipping any updates. 34 | 35 | ## Development 36 | 37 | ### Useful commands/links 38 | 39 | - https://gitlab.com/-/graphql-explorer - Gitlab API's GraphiQL 40 | - `cat example.dhall | dhall-to-json` - normalize example and convert to JSON 41 | - `http post :8080/webhook @path-to-file.json` - send fake webhook event from file 42 | 43 | ### Related projects 44 | 45 | We're using https://github.com/polyvariant/caliban-gitlab for some communication with Gitlab, 46 | as well as https://github.com/softwaremill/tapir + https://github.com/softwaremill/sttp for the actions not available via the GraphQL API. 47 | 48 | ### Docker 49 | 50 | You're going to need docker and docker-compose (or podman/podman-compose, although it hasn't been confirmed to work here yet). 51 | 52 | You can use the setup in the `docker` directory to run Scala Steward with [the test repository](https://gitlab.com/kubukoz/demo), or customize it to your needs. 53 | Checkout https://github.com/scala-steward-org/scala-steward/blob/master/docs/running.md#running-scala-steward for more information. 54 | You'll need to add a `pass.sh` file that prints your GitLab token to standard output when run (consult the Scala Steward docs to see how). 55 | 56 | After you're all set-up, run `docker-compose up` inside the `docker` directory (or `docker-compose -f docker/docker-compose.yml up` in the project directory). 57 | 58 | ## Releasing 59 | 60 | Docker images are being pushed on every push to `main` and tags starting with `v`. 61 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | # This file was automatically generated by sbt-github-actions using the 2 | # githubWorkflowGenerate task. You should add and commit this file to 3 | # your git repository. It goes without saying that you shouldn't edit 4 | # this file by hand! Instead, if you wish to make changes, you should 5 | # change your sbt build configuration to revise the workflow description 6 | # to meet your needs, then regenerate this file. 7 | 8 | name: Continuous Integration 9 | 10 | on: 11 | pull_request: 12 | branches: ['**'] 13 | push: 14 | branches: ['**'] 15 | 16 | env: 17 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 18 | 19 | jobs: 20 | build: 21 | name: Build and Test 22 | strategy: 23 | matrix: 24 | os: [ubuntu-latest] 25 | scala: [3.1.2] 26 | java: [graalvm-ce-java11@20.1.0] 27 | runs-on: ${{ matrix.os }} 28 | steps: 29 | - name: Checkout current branch (full) 30 | uses: actions/checkout@v2 31 | with: 32 | fetch-depth: 0 33 | 34 | - name: Setup Java and Scala 35 | uses: olafurpg/setup-scala@v13 36 | with: 37 | java-version: ${{ matrix.java }} 38 | 39 | - name: Cache sbt 40 | uses: actions/cache@v2 41 | with: 42 | path: | 43 | ~/.sbt 44 | ~/.ivy2/cache 45 | ~/.coursier/cache/v1 46 | ~/.cache/coursier/v1 47 | ~/AppData/Local/Coursier/Cache/v1 48 | ~/Library/Caches/Coursier/v1 49 | key: ${{ runner.os }}-sbt-cache-v2-${{ hashFiles('**/*.sbt') }}-${{ hashFiles('project/build.properties') }} 50 | 51 | - name: Check that workflows are up to date 52 | run: sbt ++${{ matrix.scala }} githubWorkflowCheck 53 | 54 | - run: sbt ++${{ matrix.scala }} test missinglinkCheck 55 | 56 | - name: Build native image 57 | run: sbt bootstrap/nativeImage 58 | 59 | - name: Upload binary 60 | uses: actions/upload-artifact@v2 61 | with: 62 | name: pitgull-bootstrap-${{ matrix.os }} 63 | path: ./bootstrap/target/native-image/bootstrap 64 | 65 | - name: Compress target directories 66 | run: tar cf targets.tar target gitlab/target core/target project/target 67 | 68 | - name: Upload target directories 69 | uses: actions/upload-artifact@v2 70 | with: 71 | name: target-${{ matrix.os }}-${{ matrix.scala }}-${{ matrix.java }} 72 | path: targets.tar 73 | 74 | publish: 75 | name: Publish Artifacts 76 | needs: [build] 77 | if: github.event_name != 'pull_request' && (startsWith(github.ref, 'refs/heads/main') || startsWith(github.ref, 'refs/tags/main')) 78 | strategy: 79 | matrix: 80 | os: [ubuntu-latest] 81 | scala: [3.1.2] 82 | java: [graalvm-ce-java11@20.1.0] 83 | runs-on: ${{ matrix.os }} 84 | steps: 85 | - name: Checkout current branch (full) 86 | uses: actions/checkout@v2 87 | with: 88 | fetch-depth: 0 89 | 90 | - name: Setup Java and Scala 91 | uses: olafurpg/setup-scala@v13 92 | with: 93 | java-version: ${{ matrix.java }} 94 | 95 | - name: Cache sbt 96 | uses: actions/cache@v2 97 | with: 98 | path: | 99 | ~/.sbt 100 | ~/.ivy2/cache 101 | ~/.coursier/cache/v1 102 | ~/.cache/coursier/v1 103 | ~/AppData/Local/Coursier/Cache/v1 104 | ~/Library/Caches/Coursier/v1 105 | key: ${{ runner.os }}-sbt-cache-v2-${{ hashFiles('**/*.sbt') }}-${{ hashFiles('project/build.properties') }} 106 | 107 | - name: Download target directories (3.1.2) 108 | uses: actions/download-artifact@v2 109 | with: 110 | name: target-${{ matrix.os }}-3.1.2-${{ matrix.java }} 111 | 112 | - name: Inflate target directories (3.1.2) 113 | run: | 114 | tar xf targets.tar 115 | rm targets.tar 116 | 117 | - uses: docker/login-action@v1 118 | with: 119 | username: kubukoz 120 | password: ${{ secrets.DOCKER_HUB_TOKEN }} 121 | 122 | - run: 'sbt ++${{ matrix.scala }} docker:publish' 123 | -------------------------------------------------------------------------------- /dhall/json.dhall: -------------------------------------------------------------------------------- 1 | let core = ./core.dhall 2 | 3 | let TextMatcher = core.TextMatcher 4 | 5 | let Matcher = core.Matcher 6 | 7 | let Action = core.Action 8 | 9 | let Rule = core.Rule 10 | 11 | let ProjectConfig = core.ProjectConfig 12 | 13 | let List/map = 14 | https://prelude.dhall-lang.org/v16.0.0/List/map sha256:dd845ffb4568d40327f2a817eb42d1c6138b929ca758d50bc33112ef3c885680 15 | 16 | let JSON = 17 | https://prelude.dhall-lang.org/v16.0.0/JSON/package.dhall sha256:1b02c5ff4710f90ee3f8dc1a2565f1b52b45e5317e2df4775307e2ba0cadcf21 18 | 19 | let listOf = 20 | λ(label : Text) → 21 | λ(values : List JSON.Type) → 22 | JSON.object 23 | (toMap { kind = JSON.string label, values = JSON.array values }) 24 | 25 | let toJsonFolds = 26 | { TextMatcher = 27 | λ(tm : TextMatcher) → 28 | tm 29 | JSON.Type 30 | { Equals = 31 | λ(args : { value : Text }) → 32 | JSON.object 33 | ( toMap 34 | { kind = JSON.string "Equals" 35 | , value = JSON.string args.value 36 | } 37 | ) 38 | , Matches = 39 | λ(args : { regex : Text }) → 40 | JSON.object 41 | ( toMap 42 | { kind = JSON.string "Matches" 43 | , regex = JSON.string args.regex 44 | } 45 | ) 46 | } 47 | , Matcher = 48 | λ(textMatcherToJson : TextMatcher → JSON.Type) → 49 | λ(matcher : Matcher) → 50 | matcher 51 | JSON.Type 52 | { Author = 53 | λ(args : { email : TextMatcher }) → 54 | JSON.object 55 | ( toMap 56 | { kind = JSON.string "Author" 57 | , email = textMatcherToJson args.email 58 | } 59 | ) 60 | , Description = 61 | λ(args : { text : TextMatcher }) → 62 | JSON.object 63 | ( toMap 64 | { kind = JSON.string "Description" 65 | , text = textMatcherToJson args.text 66 | } 67 | ) 68 | , PipelineStatus = 69 | λ(args : { status : Text }) → 70 | JSON.object 71 | ( toMap 72 | { kind = JSON.string "PipelineStatus" 73 | , status = JSON.string args.status 74 | } 75 | ) 76 | , Many = listOf "Many" 77 | , OneOf = listOf "OneOf" 78 | , Not = 79 | λ(underlying : JSON.Type) → 80 | JSON.object 81 | (toMap { kind = JSON.string "Not", underlying }) 82 | } 83 | , Action = 84 | λ(action : Action) → 85 | action JSON.Type { Merge = JSON.string "Merge" } 86 | } 87 | : { TextMatcher : TextMatcher → JSON.Type 88 | , Matcher : (TextMatcher → JSON.Type) → Matcher → JSON.Type 89 | , Action : Action → JSON.Type 90 | } 91 | 92 | let projectToJson 93 | : ProjectConfig → JSON.Type 94 | = λ(config : ProjectConfig) → 95 | let matcherToJson = toJsonFolds.Matcher toJsonFolds.TextMatcher 96 | 97 | let ruleToJson 98 | : Rule → JSON.Type 99 | = λ(rule : Rule) → 100 | JSON.object 101 | ( toMap 102 | { name = JSON.string rule.name 103 | , matcher = matcherToJson rule.matcher 104 | , action = toJsonFolds.Action rule.action 105 | } 106 | ) 107 | 108 | in JSON.object 109 | ( toMap 110 | { rules = 111 | JSON.array 112 | (List/map Rule JSON.Type ruleToJson config.rules) 113 | } 114 | ) 115 | 116 | in projectToJson 117 | -------------------------------------------------------------------------------- /src/test/scala/io/pg/fakes/ProjectActionsStateFake.scala: -------------------------------------------------------------------------------- 1 | package io.pg.fakes 2 | 3 | import cats.Monad 4 | import cats.data.Chain 5 | import cats.effect.Ref 6 | import cats.implicits._ 7 | import cats.mtl.Stateful 8 | import io.odin.Logger 9 | import io.pg.MergeRequestState 10 | import io.pg.MergeRequestState.Mergeability 11 | import io.pg.ProjectAction 12 | import io.pg.ProjectAction.Merge 13 | import io.pg.ProjectAction.Rebase 14 | import io.pg.ProjectActions 15 | import io.pg.StateResolver 16 | import io.pg.gitlab.Gitlab.MergeRequestInfo 17 | import io.pg.gitlab.webhook.Project 18 | import monocle.syntax.all._ 19 | 20 | object ProjectActionsStateFake { 21 | sealed case class MergeRequestDescription(projectId: Long, mergeRequestIid: Long) 22 | 23 | object MergeRequestDescription { 24 | val fromMergeAction: ProjectAction.Merge => MergeRequestDescription = merge => 25 | MergeRequestDescription(merge.projectId, merge.mergeRequestIid) 26 | } 27 | 28 | sealed case class State( 29 | mergeRequests: Map[MergeRequestDescription, MergeRequestState], 30 | actionLog: Chain[ProjectAction] 31 | ) 32 | 33 | object State { 34 | val initial: State = State(Map.empty, Chain.nil) 35 | 36 | /** A collection of modifiers on the state, which will be provided together with the instance using it. 37 | */ 38 | trait Modifiers[F[_]] { 39 | // returns Iid of created MR 40 | def open(projectId: Long, authorUsername: String, description: Option[String]): F[Long] 41 | def finishPipeline(projectId: Long, mergeRequestIid: Long): F[Unit] 42 | def setMergeability(projectId: Long, mergeRequestIid: Long, mergeability: Mergeability): F[Unit] 43 | def getActionLog: F[List[ProjectAction]] 44 | } 45 | 46 | private[ProjectActionsStateFake] object modifications { 47 | 48 | def logAction(action: ProjectAction): State => State = 49 | _.focus(_.actionLog).modify(_.append(action)) 50 | 51 | def merge(action: ProjectAction.Merge): State => State = 52 | _.focus(_.mergeRequests).modify(_ - MergeRequestDescription.fromMergeAction(action)) 53 | 54 | def rebase(action: ProjectAction.Rebase): State => State = 55 | // Note: this doesn't check for conflicts 56 | setMergeabilityInternal(action.projectId, action.mergeRequestIid, Mergeability.CanMerge) 57 | 58 | def setMergeabilityInternal(projectId: Long, mergeRequestIid: Long, mergeability: Mergeability): State => State = 59 | _.focus(_.mergeRequests).modify { mrs => 60 | val key = MergeRequestDescription(projectId, mergeRequestIid) 61 | mrs ++ mrs.get(key).map(_.copy(mergeability = mergeability)).tupleLeft(key) 62 | } 63 | 64 | def save(key: MergeRequestDescription, state: MergeRequestState) = (_: State).focus(_.mergeRequests).modify { 65 | _ + (key -> state) 66 | } 67 | 68 | def finishPipeline(key: MergeRequestDescription) = (_: State).focus(_.mergeRequests).modify { 69 | _.updatedWith(key) { 70 | _.map { state => 71 | state.copy(status = MergeRequestInfo.Status.Success) 72 | } 73 | } 74 | } 75 | 76 | } 77 | 78 | } 79 | 80 | type Data[F[_]] = Stateful[F, State] 81 | def Data[F[_]: Data]: Data[F] = implicitly[Data[F]] 82 | 83 | def refInstance[F[_]: Ref.Make: Logger: Monad]: F[ProjectActions[F] with StateResolver[F] with State.Modifiers[F]] = 84 | Ref[F].of(State.initial).map(FakeUtils.statefulRef(_)).map(implicit F => instance[F]) 85 | 86 | /** This instance has both the capabilities of ProjectActions and StateResolver, because they operate on the same state, and the state is 87 | * sealed by convention. 88 | */ 89 | def instance[ 90 | F[_]: Data: Monad: Logger 91 | ]: ProjectActions[F] with StateResolver[F] with State.Modifiers[F] = new ProjectActions[F] with StateResolver[F] with State.Modifiers[F] { 92 | 93 | type Action = ProjectAction 94 | def resolve(mr: MergeRequestState): F[Option[ProjectAction]] = ProjectActions.defaultResolve[F](mr) 95 | 96 | def execute(action: ProjectAction): F[Unit] = Data[F].modify { 97 | val actionChange = action match { 98 | case m: Merge => State.modifications.merge(m) 99 | case r: Rebase => State.modifications.rebase(r) 100 | } 101 | 102 | actionChange >>> State.modifications.logAction(action) 103 | } 104 | 105 | def resolve(project: Project): F[List[MergeRequestState]] = 106 | Data[F].get.map(_.mergeRequests).map(_.values.toList) 107 | 108 | def open(projectId: Long, authorUsername: String, description: Option[String]): F[Long] = { 109 | 110 | val getNextId = Data[F] 111 | .get 112 | .map( 113 | _.mergeRequests 114 | .values 115 | .filter(_.projectId === projectId) 116 | .map(_.mergeRequestIid) 117 | .maxOption 118 | .getOrElse(0L) + 1L 119 | ) 120 | 121 | getNextId.flatTap { newId => 122 | val initState = MergeRequestState( 123 | projectId = projectId, 124 | mergeRequestIid = newId, 125 | authorUsername = authorUsername, 126 | description = description, 127 | status = MergeRequestInfo.Status.Other("Created"), 128 | mergeability = Mergeability.CanMerge 129 | ) 130 | 131 | Data[F].modify { 132 | State.modifications.save(MergeRequestDescription(projectId, newId), initState) 133 | } 134 | } 135 | } 136 | 137 | def setMergeability(projectId: Long, mergeRequestIid: Long, mergeability: Mergeability): F[Unit] = Data[F].modify { 138 | State.modifications.setMergeabilityInternal(projectId, mergeRequestIid, mergeability) 139 | } 140 | 141 | def finishPipeline(projectId: Long, mergeRequestIid: Long): F[Unit] = 142 | Data[F].modify { 143 | State.modifications.finishPipeline(MergeRequestDescription(projectId, mergeRequestIid)) 144 | } 145 | 146 | def getActionLog: F[List[ProjectAction]] = Data[F].inspect(_.actionLog.toList) 147 | } 148 | 149 | } 150 | -------------------------------------------------------------------------------- /src/test/scala/io/pg/WebhookProcessorTest.scala: -------------------------------------------------------------------------------- 1 | package io.pg 2 | 3 | import cats.effect.IO 4 | import cats.implicits._ 5 | import cats.effect.implicits._ 6 | import io.pg.config.ProjectConfig 7 | import io.pg.config.ProjectConfigReader 8 | import io.pg.fakes.ProjectActionsStateFake 9 | import io.pg.fakes.ProjectConfigReaderFake 10 | import io.pg.gitlab.webhook.Project 11 | import io.pg.gitlab.webhook.WebhookEvent 12 | import io.pg.webhook.WebhookProcessor 13 | import weaver.Expectations 14 | import weaver.SimpleIOSuite 15 | import io.pg.config.Rule 16 | import io.pg.config.Matcher 17 | import io.pg.config.Action 18 | import io.pg.config.TextMatcher 19 | import io.pg.MergeRequestState.Mergeability 20 | import io.odin.Logger 21 | 22 | object WebhookProcessorTest extends SimpleIOSuite { 23 | 24 | final case class Resources[F[_]]( 25 | actions: ProjectActions[F], 26 | resolver: StateResolver[F], 27 | projectModifiers: ProjectActionsStateFake.State.Modifiers[F], 28 | projectConfigs: ProjectConfigReader[F], 29 | projectConfigModifiers: ProjectConfigReaderFake.State.Modifiers[F], 30 | process: WebhookEvent => F[Unit] 31 | ) 32 | 33 | val mkResources = 34 | ProjectConfigReaderFake 35 | .refInstance[IO] 36 | .flatMap { implicit configReader => 37 | given Logger[IO] = io.odin.consoleLogger[IO]() 38 | 39 | ProjectActionsStateFake.refInstance[IO].map { implicit projects => 40 | implicit val mergeRequests: MergeRequests[IO] = MergeRequests.instance[IO] 41 | 42 | Resources( 43 | actions = projects, 44 | resolver = projects, 45 | projectModifiers = projects, 46 | projectConfigs = configReader, 47 | projectConfigModifiers = configReader, 48 | process = WebhookProcessor.instance[IO] 49 | ) 50 | } 51 | } 52 | .toResource 53 | 54 | def testWithResources(name: String)(use: Resources[IO] => IO[Expectations]) = 55 | test(name)(mkResources.use(use)) 56 | /* 57 | testWithResources("unknown project") { resources => 58 | import resources._ 59 | val projectId = 66L 60 | 61 | process(WebhookEvent(Project(projectId), "merge_request")) 62 | .attempt 63 | .map { result => 64 | expect(result.isLeft) 65 | } 66 | } 67 | 68 | testWithResources("known project with no MRs") { resources => 69 | import resources._ 70 | val projectId = 66L 71 | 72 | projectConfigModifiers.register(projectId, ProjectConfig.empty) *> 73 | process(WebhookEvent(Project(projectId), "merge_request")).as(success) 74 | } 75 | 76 | testWithResources("known project with one mergeable MR and one non-mergeable MR") { resources => 77 | import resources._ 78 | val projectId = 66L 79 | 80 | val project = Project(projectId) 81 | 82 | val matchSuccessfulPipeline = 83 | Rule("pipeline successful", Matcher.PipelineStatus("success"), Action.Merge) 84 | 85 | for { 86 | _ <- projectConfigModifiers.register(projectId, ProjectConfig(List(matchSuccessfulPipeline))) 87 | mergeRequestId <- projectModifiers.open(projectId, "anyone@example.com", None) 88 | _ <- projectModifiers.finishPipeline(projectId, mergeRequestId) 89 | freshMR <- projectModifiers.open(projectId, "anyone@example.com", None) 90 | 91 | _ <- process(WebhookEvent(project, "merge_request")) 92 | mergeRequestsAfterProcess <- resolver.resolve(project) 93 | } yield expect { 94 | mergeRequestsAfterProcess.map(_.mergeRequestIid) == List(freshMR) 95 | } 96 | } 97 | */ 98 | testWithResources("known project with one mergeable MR and one rebaseable MR") { resources => 99 | import resources._ 100 | val projectId = 66L 101 | 102 | val project = Project(projectId) 103 | 104 | val perform = (process(WebhookEvent(project, "merge_request")) *> resolver.resolve(project), projectModifiers.getActionLog).tupled 105 | 106 | for { 107 | _ <- projectConfigModifiers.register(projectId, ProjectConfig(List(Rule.mergeAnything))) 108 | mr1 <- projectModifiers.open(projectId, "anyone@example.com", None) 109 | mr2 <- projectModifiers.open(projectId, "anyone@example.com", None) 110 | _ <- projectModifiers.finishPipeline(projectId, mr1) 111 | _ <- projectModifiers.finishPipeline(projectId, mr2) 112 | _ <- projectModifiers.setMergeability(projectId, mr2, Mergeability.NeedsRebase) 113 | 114 | result1 <- perform 115 | result2 <- perform 116 | result3 <- perform 117 | } yield { 118 | val (mergeRequestsAfterProcess1, logAfterProcess1) = result1 119 | val (mergeRequestsAfterProcess2, logAfterProcess2) = result2 120 | val (mergeRequestsAfterProcess3, logAfterProcess3) = result3 121 | 122 | val merge1 = ProjectAction.Merge(projectId, mr1) 123 | val rebase2 = ProjectAction.Rebase(projectId, mr2) 124 | val merge2 = ProjectAction.Merge(projectId, mr2) 125 | 126 | val firstMerged = expect(mergeRequestsAfterProcess1.map(_.mergeRequestIid) == List(mr2)) && 127 | expect(logAfterProcess1 == List(merge1)) 128 | 129 | val secondRebased = expect(mergeRequestsAfterProcess2.map(_.mergeRequestIid) == List(mr2)) && 130 | expect(logAfterProcess2 == List(merge1, rebase2)) 131 | 132 | val secondMerged = expect(mergeRequestsAfterProcess3.map(_.mergeRequestIid) == Nil) && 133 | expect(logAfterProcess3 == List(merge1, rebase2, merge2)) 134 | 135 | firstMerged && 136 | secondRebased && 137 | secondMerged 138 | } 139 | } 140 | 141 | testWithResources("known project with one mergeable MR - matching by author") { resources => 142 | import resources._ 143 | val projectId = 66L 144 | 145 | val project = Project(projectId) 146 | 147 | val correctDomainRegex = ".*@example.com".r 148 | 149 | val matchAuthorUsernameDomain = 150 | Rule("pipeline successful", Matcher.Author(TextMatcher.Matches(correctDomainRegex)), Action.Merge) 151 | 152 | for { 153 | _ <- projectConfigModifiers.register(projectId, ProjectConfig(List(matchAuthorUsernameDomain))) 154 | mergeRequestId <- projectModifiers.open(projectId, "anyone@example.com", None) 155 | _ <- projectModifiers.finishPipeline(projectId, mergeRequestId) 156 | _ <- process(WebhookEvent(project, "merge_request")) 157 | mergeRequestsAfterProcess <- resolver.resolve(project) 158 | } yield expect { 159 | mergeRequestsAfterProcess.map(_.mergeRequestIid) == Nil 160 | } 161 | } 162 | 163 | } 164 | -------------------------------------------------------------------------------- /bootstrap/src/main/scala/org/polyvariant/Gitlab.scala: -------------------------------------------------------------------------------- 1 | package org.polyvariant 2 | 3 | import cats.implicits.* 4 | 5 | import scala.util.chaining.* 6 | import io.pg.gitlab.graphql.* 7 | import sttp.model.Uri 8 | import sttp.client3.* 9 | import sttp.client3.circe.* 10 | import caliban.client.SelectionBuilder 11 | import caliban.client.CalibanClientError.DecodingError 12 | import io.pg.gitlab.graphql.MergeRequest 13 | import io.pg.gitlab.graphql.MergeRequestConnection 14 | import io.pg.gitlab.graphql.MergeRequestState 15 | import io.pg.gitlab.graphql.Pipeline 16 | import io.pg.gitlab.graphql.PipelineStatusEnum 17 | import io.pg.gitlab.graphql.Project 18 | import io.pg.gitlab.graphql.ProjectConnection 19 | import io.pg.gitlab.graphql.Query 20 | import io.pg.gitlab.graphql.UserCore 21 | import caliban.client.Operations.IsOperation 22 | import sttp.model.Method 23 | import cats.MonadThrow 24 | import io.circe.* 25 | 26 | trait Gitlab[F[_]] { 27 | def mergeRequests(projectId: Long): F[List[Gitlab.MergeRequestInfo]] 28 | def deleteMergeRequest(projectId: Long, mergeRequestId: Long): F[Unit] 29 | def createWebhook(projectId: Long, pitgullUrl: Uri): F[Unit] 30 | def listWebhooks(projectId: Long): F[List[Gitlab.Webhook]] 31 | } 32 | 33 | object Gitlab { 34 | 35 | def apply[F[_]](using ev: Gitlab[F]): Gitlab[F] = ev 36 | 37 | def sttpInstance[F[_]: Logger: MonadThrow]( 38 | baseUri: Uri, 39 | accessToken: String 40 | )( 41 | using backend: SttpBackend[Identity, Any] // FIXME: https://github.com/polyvariant/pitgull/issues/265 42 | ): Gitlab[F] = { 43 | def runRequest[O](request: Request[O, Any]): F[O] = 44 | request 45 | .header("Private-Token", accessToken) 46 | .send(backend) 47 | .pure[F] 48 | .map(_.body) // FIXME - change in https://github.com/polyvariant/pitgull/issues/265 49 | 50 | def runGraphQLQuery[A: IsOperation, B](a: SelectionBuilder[A, B]): F[B] = 51 | runRequest(a.toRequest(baseUri.addPath("api", "graphql"))).rethrow 52 | 53 | new Gitlab[F] { 54 | def mergeRequests(projectId: Long): F[List[MergeRequestInfo]] = 55 | Logger[F].info(s"Looking up merge requests for project: $projectId") *> 56 | mergeRequestsQuery(projectId) 57 | .mapEither(_.toRight(DecodingError("Project not found"))) 58 | .pipe(runGraphQLQuery(_)) 59 | .flatTap { result => 60 | Logger[F].info(s"Found merge requests. Size: ${result.size}") 61 | } 62 | 63 | def deleteMergeRequest(projectId: Long, mergeRequestId: Long): F[Unit] = for { 64 | _ <- Logger[F].debug(s"Request to remove $mergeRequestId") 65 | result <- runRequest( 66 | basicRequest.delete( 67 | baseUri 68 | .addPath( 69 | Seq( 70 | "api", 71 | "v4", 72 | "projects", 73 | projectId.toString, 74 | "merge_requests", 75 | mergeRequestId.toString 76 | ) 77 | ) 78 | ) 79 | ) 80 | } yield () 81 | 82 | def createWebhook(projectId: Long, pitgullUrl: Uri): F[Unit] = for { 83 | _ <- Logger[F].debug(s"Creating webhook to $pitgullUrl") 84 | result <- runRequest( 85 | basicRequest 86 | .post( 87 | baseUri 88 | .addPath( 89 | Seq( 90 | "api", 91 | "v4", 92 | "projects", 93 | projectId.toString, 94 | "hooks" 95 | ) 96 | ) 97 | ) 98 | .body(s"""{"merge_requests_events": true, "pipeline_events": true, "note_events": true, "url": "$pitgullUrl"}""") 99 | .contentType("application/json") 100 | ) 101 | } yield () 102 | 103 | def listWebhooks(projectId: Long): F[List[Webhook]] = for { 104 | _ <- Logger[F].debug(s"Listing webhooks for $projectId") 105 | response <- runRequest( 106 | basicRequest 107 | .get( 108 | baseUri 109 | .addPath( 110 | Seq( 111 | "api", 112 | "v4", 113 | "projects", 114 | projectId.toString, 115 | "hooks" 116 | ) 117 | ) 118 | ) 119 | .response(asJson[List[Webhook]]) 120 | ).flatMap(_.liftTo[F]) 121 | _ <- Logger[F].debug(response.toString) 122 | } yield response 123 | } 124 | 125 | } 126 | 127 | final case class Webhook( 128 | id: Long, 129 | url: String 130 | ) derives Codec.AsObject 131 | 132 | final case class MergeRequestInfo( 133 | projectId: Long, 134 | mergeRequestIid: Long, 135 | authorUsername: String, 136 | description: Option[String], 137 | needsRebase: Boolean, 138 | hasConflicts: Boolean 139 | ) 140 | 141 | private def flattenTheEarth[A]: Option[List[Option[Option[Option[List[Option[A]]]]]]] => List[A] = 142 | _.toList.flatten.flatten.flatten.flatten.flatten.flatten 143 | 144 | private def mergeRequestInfoSelection(projectId: Long): SelectionBuilder[MergeRequest, MergeRequestInfo] = ( 145 | MergeRequest.iid.mapEither(_.toLongOption.toRight(DecodingError("MR IID wasn't a Long"))) ~ 146 | MergeRequest 147 | .author(UserCore.username) 148 | .mapEither(_.toRight(DecodingError("MR has no author"))) ~ 149 | MergeRequest.description ~ 150 | MergeRequest.shouldBeRebased ~ 151 | MergeRequest.conflicts 152 | ).mapN(buildMergeRequest(projectId) _) 153 | 154 | private def buildMergeRequest( 155 | projectId: Long 156 | )( 157 | mergeRequestIid: Long, 158 | authorUsername: String, 159 | description: Option[String], 160 | needsRebase: Boolean, 161 | hasConflicts: Boolean 162 | ): MergeRequestInfo = MergeRequestInfo( 163 | projectId = projectId, 164 | mergeRequestIid = mergeRequestIid, 165 | authorUsername = authorUsername, 166 | description = description, 167 | needsRebase = needsRebase, 168 | hasConflicts = hasConflicts 169 | ) 170 | 171 | private def mergeRequestsQuery(projectId: Long) = 172 | Query 173 | .projects(ids = List(show"gid://gitlab/Project/$projectId").some)( 174 | ProjectConnection 175 | .nodes( 176 | Project 177 | .mergeRequests( 178 | state = MergeRequestState.opened.some 179 | )( 180 | MergeRequestConnection 181 | .nodes(mergeRequestInfoSelection(projectId)) 182 | ) 183 | ) 184 | .map(flattenTheEarth) 185 | ) 186 | 187 | } 188 | -------------------------------------------------------------------------------- /src/main/scala/io/pg/actions.scala: -------------------------------------------------------------------------------- 1 | package io.pg 2 | 3 | import cats.data.EitherNel 4 | import cats.implicits._ 5 | import io.pg.ProjectAction.Merge 6 | import io.pg.config.Matcher 7 | import io.pg.config.ProjectConfig 8 | import io.pg.gitlab.Gitlab 9 | import io.odin.Logger 10 | import io.pg.gitlab.Gitlab.MergeRequestInfo 11 | import io.pg.config.TextMatcher 12 | import cats.MonoidK 13 | import cats.Show 14 | import io.pg.ProjectAction.Rebase 15 | import io.pg.MergeRequestState.Mergeability.CanMerge 16 | import io.pg.MergeRequestState.Mergeability.NeedsRebase 17 | import io.pg.MergeRequestState.Mergeability.HasConflicts 18 | import cats.Applicative 19 | import cats.data.NonEmptyList 20 | import scala.util.matching.Regex 21 | import cats.MonadThrow 22 | import cats.Contravariant 23 | 24 | trait ProjectActions[F[_]] { 25 | type Action 26 | def resolve(mr: MergeRequestState): F[Option[Action]] 27 | def execute(action: Action): F[Unit] 28 | } 29 | 30 | object ProjectActions { 31 | def apply[F[_]](implicit F: ProjectActions[F]): F.type = F 32 | 33 | def defaultResolve[F[_]: Applicative: Logger](mr: MergeRequestState): F[Option[ProjectAction]] = mr.mergeability match { 34 | case CanMerge => 35 | ProjectAction 36 | .Merge(projectId = mr.projectId, mergeRequestIid = mr.mergeRequestIid) 37 | .some 38 | .widen[ProjectAction] 39 | .pure[F] 40 | 41 | case NeedsRebase => 42 | ProjectAction 43 | .Rebase(projectId = mr.projectId, mergeRequestIid = mr.mergeRequestIid) 44 | .some 45 | .widen[ProjectAction] 46 | .pure[F] 47 | 48 | case HasConflicts => 49 | Logger[F] 50 | .info( 51 | "MR has conflicts, skipping", 52 | Map("projectId" -> mr.projectId.show, "mergeRequestIid" -> mr.mergeRequestIid.show) 53 | ) 54 | .as(none) 55 | } 56 | 57 | def instance[F[_]: Gitlab: Logger: MonadThrow]: ProjectActions[F] = new ProjectActions[F] { 58 | 59 | type Action = ProjectAction 60 | 61 | def resolve(mr: MergeRequestState): F[Option[ProjectAction]] = defaultResolve[F](mr) 62 | 63 | def execute(action: ProjectAction): F[Unit] = { 64 | val logBefore = Logger[F].info("About to execute action", Map("action" -> action.toString)) 65 | 66 | val approve = action match { 67 | case Merge(projectId, mergeRequestIid) => 68 | Logger[F].info("Forcing approval befor merge", Map("action" -> action.toString)) *> 69 | Gitlab[F].forceApprove(projectId, mergeRequestIid) 70 | case _ => 71 | Logger[F].info("Approval forcing not required", Map("action" -> action.toString)) 72 | } 73 | 74 | val perform = action match { 75 | // todo: perform check is the MR still open? 76 | // or fall back in case it's not 77 | // https://www.youtube.com/watch?v=vxKBHX9Datw 78 | case Merge(projectId, mergeRequestIid) => 79 | Gitlab[F].acceptMergeRequest(projectId, mergeRequestIid) 80 | 81 | case Rebase(projectId, mergeRequestIid) => 82 | Gitlab[F].rebaseMergeRequest(projectId, mergeRequestIid) 83 | } 84 | 85 | logBefore *> approve *> perform.handleErrorWith { error => 86 | Logger[F] 87 | .error( 88 | "Couldn't perform action", 89 | Map( 90 | // todo: consier granular fields 91 | "action" -> action.toString 92 | ), 93 | error 94 | ) 95 | } 96 | } 97 | 98 | } 99 | 100 | trait MatcherFunction[-In] { 101 | def matches(in: In): Matched[Unit] 102 | def atPath(path: String): MatcherFunction[In] = mapFailures(_.map(_.atPath(path))) 103 | 104 | def mapResult(f: Matched[Unit] => Matched[Unit]): MatcherFunction[In] = f.compose(matches).apply(_) 105 | def mapFailures(f: NonEmptyList[Mismatch] => NonEmptyList[Mismatch]): MatcherFunction[In] = mapResult(_.leftMap(f)) 106 | } 107 | 108 | object MatcherFunction { 109 | 110 | implicit val contravariantMatcherFunction: Contravariant[MatcherFunction] = new Contravariant[MatcherFunction] { 111 | def contramap[A, B](fa: MatcherFunction[A])(f: B => A): MatcherFunction[B] = b => fa.matches(f(b)) 112 | } 113 | 114 | implicit val monoidK: MonoidK[MatcherFunction] = new MonoidK[MatcherFunction] { 115 | override def combineK[A](x: MatcherFunction[A], y: MatcherFunction[A]): MatcherFunction[A] = 116 | in => (x.matches(in).toValidated |+| y.matches(in).toValidated).toEither 117 | override def empty[A]: MatcherFunction[A] = success 118 | } 119 | 120 | def fromPredicate[In]( 121 | predicate: In => Boolean, 122 | orElse: In => Mismatch 123 | ): MatcherFunction[In] = 124 | _.asRight[Mismatch].ensureOr(orElse)(predicate).toEitherNel.void 125 | 126 | val success: MatcherFunction[Any] = 127 | _.pure[Matched].void 128 | } 129 | 130 | sealed trait Mismatch extends Product with Serializable { 131 | def atPath(path: String): Mismatch = Mismatch.AtPath(path, this) 132 | } 133 | 134 | object Mismatch { 135 | final case class AtPath(path: String, mismatch: Mismatch) extends Mismatch 136 | final case class ValueMismatch(expected: String, actual: String) extends Mismatch 137 | final case class RegexMismatch(pattern: Regex, actual: String) extends Mismatch 138 | final case class ManyFailed(incompleteMatches: List[NonEmptyList[Mismatch]]) extends Mismatch 139 | case object ValueEmpty extends Mismatch 140 | case object NegationFailed extends Mismatch 141 | 142 | implicit val show: Show[Mismatch] = Show.fromToString 143 | } 144 | 145 | type Matched[A] = EitherNel[Mismatch, A] 146 | 147 | def statusMatches(expectedStatus: String): MatcherFunction[MergeRequestState] = 148 | MatcherFunction 149 | .fromPredicate[MergeRequestInfo.Status]( 150 | { 151 | case MergeRequestInfo.Status.Success => expectedStatus.toLowerCase === "success" 152 | case MergeRequestInfo.Status.Other(value) => expectedStatus === value 153 | }, 154 | value => Mismatch.ValueMismatch(expectedStatus, value.toString) 155 | ) 156 | .atPath(".status") 157 | .contramap(_.status) 158 | 159 | val matchTextMatcher: TextMatcher => MatcherFunction[String] = { 160 | case TextMatcher.Equals(expected) => 161 | MatcherFunction.fromPredicate( 162 | _ === expected, 163 | Mismatch.ValueMismatch(expected, _) 164 | ) 165 | case TextMatcher.Matches(regex) => 166 | MatcherFunction.fromPredicate( 167 | regex.matches, 168 | Mismatch.RegexMismatch(regex, _) 169 | ) 170 | } 171 | 172 | def exists[A](base: MatcherFunction[A]): MatcherFunction[Option[A]] = 173 | _.fold[Matched[Unit]](Mismatch.ValueEmpty.leftNel)(base.matches) 174 | 175 | def oneOf[A](matchers: List[MatcherFunction[A]]): MatcherFunction[A] = input => 176 | matchers 177 | .traverse(_.matches(input).swap) 178 | .swap 179 | .leftMap(Mismatch.ManyFailed.apply) 180 | .toEitherNel 181 | 182 | def not[A](matcher: MatcherFunction[A]): MatcherFunction[A] = input => 183 | matcher.matches(input).swap.leftMap(_ => Mismatch.NegationFailed).void.toEitherNel 184 | 185 | def autorMatches(matcher: TextMatcher): MatcherFunction[MergeRequestState] = 186 | matchTextMatcher(matcher) 187 | .atPath(".author") 188 | .contramap(_.authorUsername) 189 | 190 | def descriptionMatches(matcher: TextMatcher): MatcherFunction[MergeRequestState] = 191 | exists(matchTextMatcher(matcher)) 192 | .atPath(".description") 193 | .contramap(_.description) 194 | 195 | val compileMatcher: Matcher => MatcherFunction[MergeRequestState] = { 196 | case Matcher.Author(email) => autorMatches(email) 197 | case Matcher.Description(text) => descriptionMatches(text) 198 | case Matcher.PipelineStatus(status) => statusMatches(status) 199 | case Matcher.Many(values) => values.foldMapK(compileMatcher) 200 | case Matcher.OneOf(values) => oneOf(values.map(compileMatcher)) 201 | case Matcher.Not(underlying) => not(compileMatcher(underlying)) 202 | } 203 | 204 | def compile( 205 | state: MergeRequestState, 206 | project: ProjectConfig 207 | ): List[EitherNel[Mismatch, MergeRequestState]] = 208 | project.rules.map { rule => 209 | compileMatcher(rule.matcher).matches(state).as(state) 210 | } 211 | 212 | } 213 | 214 | enum ProjectAction { 215 | case Merge(projectId: Long, mergeRequestIid: Long) 216 | case Rebase(projectId: Long, mergeRequestIid: Long) 217 | } 218 | -------------------------------------------------------------------------------- /gitlab/src/test/scala/io/pg/gitlab/webhook/WebhookFormatTests.scala: -------------------------------------------------------------------------------- 1 | package io.pg.gitlab.webhook 2 | 3 | import cats.implicits._ 4 | import io.circe.literal._ 5 | import weaver.SimpleIOSuite 6 | 7 | object WebhookFormatTests extends SimpleIOSuite { 8 | pureTest("webhook push event") { 9 | val source = json""" 10 | { 11 | "object_kind": "push", 12 | "before": "95790bf891e76fee5e1747ab589903a6a1f80f22", 13 | "after": "da1560886d4f094c3e6c9ef40349f7d38b5d27d7", 14 | "ref": "refs/heads/master", 15 | "checkout_sha": "da1560886d4f094c3e6c9ef40349f7d38b5d27d7", 16 | "user_id": 4, 17 | "user_name": "John Smith", 18 | "user_username": "jsmith", 19 | "user_email": "john@example.com", 20 | "user_avatar": "https://s.gravatar.com/avatar/d4c74594d841139328695756648b6bd6?s=8://s.gravatar.com/avatar/d4c74594d841139328695756648b6bd6?s=80", 21 | "project_id": 15, 22 | "project":{ 23 | "id": 15, 24 | "name":"Diaspora", 25 | "description":"", 26 | "web_url":"http://example.com/mike/diaspora", 27 | "avatar_url":null, 28 | "git_ssh_url":"git@example.com:mike/diaspora.git", 29 | "git_http_url":"http://example.com/mike/diaspora.git", 30 | "namespace":"Mike", 31 | "visibility_level":0, 32 | "path_with_namespace":"mike/diaspora", 33 | "default_branch":"master", 34 | "homepage":"http://example.com/mike/diaspora", 35 | "ssh_url":"git@example.com:mike/diaspora.git", 36 | "http_url":"http://example.com/mike/diaspora.git" 37 | }, 38 | "repository":{ 39 | "name": "Diaspora", 40 | "url": "git@example.com:mike/diaspora.git", 41 | "description": "", 42 | "homepage": "http://example.com/mike/diaspora", 43 | "git_http_url":"http://example.com/mike/diaspora.git", 44 | "git_ssh_url":"git@example.com:mike/diaspora.git", 45 | "visibility_level":0 46 | }, 47 | "commits": [ 48 | { 49 | "id": "b6568db1bc1dcd7f8b4d5a946b0b91f9dacd7327", 50 | "message": "Update Catalan translation to e38cb41.\n\nSee https://gitlab.com/gitlab-org/gitlab for more information", 51 | "title": "Update Catalan translation to e38cb41.", 52 | "timestamp": "2011-12-12T14:27:31+02:00", 53 | "url": "http://example.com/mike/diaspora/commit/b6568db1bc1dcd7f8b4d5a946b0b91f9dacd7327", 54 | "author": { 55 | "name": "Jordi Mallach", 56 | "email": "jordi@softcatala.org" 57 | }, 58 | "added": ["CHANGELOG"], 59 | "modified": ["app/controller/application.rb"], 60 | "removed": [] 61 | }, 62 | { 63 | "id": "da1560886d4f094c3e6c9ef40349f7d38b5d27d7", 64 | "message": "fixed readme", 65 | "title": "fixed readme", 66 | "timestamp": "2012-01-03T23:36:29+02:00", 67 | "url": "http://example.com/mike/diaspora/commit/da1560886d4f094c3e6c9ef40349f7d38b5d27d7", 68 | "author": { 69 | "name": "GitLab dev user", 70 | "email": "gitlabdev@dv6700.(none)" 71 | }, 72 | "added": ["CHANGELOG"], 73 | "modified": ["app/controller/application.rb"], 74 | "removed": [] 75 | } 76 | ], 77 | "total_commits_count": 4 78 | }""" 79 | 80 | expect { 81 | source.as[WebhookEvent] == WebhookEvent( 82 | Project(id = 15), 83 | objectKind = "push" 84 | ).asRight 85 | } 86 | } 87 | 88 | pureTest("webhook pipeline event") { 89 | val source = json""" 90 | { 91 | "object_kind": "pipeline", 92 | "object_attributes":{ 93 | "id": 31, 94 | "ref": "master", 95 | "tag": false, 96 | "sha": "bcbb5ec396a2c0f828686f14fac9b80b780504f2", 97 | "before_sha": "bcbb5ec396a2c0f828686f14fac9b80b780504f2", 98 | "source": "merge_request_event", 99 | "status": "success", 100 | "stages":[ 101 | "build", 102 | "test", 103 | "deploy" 104 | ], 105 | "created_at": "2016-08-12 15:23:28 UTC", 106 | "finished_at": "2016-08-12 15:26:29 UTC", 107 | "duration": 63, 108 | "variables": [ 109 | { 110 | "key": "NESTOR_PROD_ENVIRONMENT", 111 | "value": "us-west-1" 112 | } 113 | ] 114 | }, 115 | "merge_request": { 116 | "id": 1, 117 | "iid": 1, 118 | "title": "Test", 119 | "source_branch": "test", 120 | "source_project_id": 1, 121 | "target_branch": "master", 122 | "target_project_id": 1, 123 | "state": "opened", 124 | "merge_status": "can_be_merged", 125 | "url": "http://192.168.64.1:3005/gitlab-org/gitlab-test/merge_requests/1" 126 | }, 127 | "user":{ 128 | "name": "Administrator", 129 | "username": "root", 130 | "avatar_url": "http://www.gravatar.com/avatar/e32bd13e2add097461cb96824b7a829c?s=80\u0026d=identicon", 131 | "email": "user_email@gitlab.com" 132 | }, 133 | "project":{ 134 | "id": 1, 135 | "name": "Gitlab Test", 136 | "description": "Atque in sunt eos similique dolores voluptatem.", 137 | "web_url": "http://192.168.64.1:3005/gitlab-org/gitlab-test", 138 | "avatar_url": null, 139 | "git_ssh_url": "git@192.168.64.1:gitlab-org/gitlab-test.git", 140 | "git_http_url": "http://192.168.64.1:3005/gitlab-org/gitlab-test.git", 141 | "namespace": "Gitlab Org", 142 | "visibility_level": 20, 143 | "path_with_namespace": "gitlab-org/gitlab-test", 144 | "default_branch": "master" 145 | }, 146 | "commit":{ 147 | "id": "bcbb5ec396a2c0f828686f14fac9b80b780504f2", 148 | "message": "test\n", 149 | "timestamp": "2016-08-12T17:23:21+02:00", 150 | "url": "http://example.com/gitlab-org/gitlab-test/commit/bcbb5ec396a2c0f828686f14fac9b80b780504f2", 151 | "author":{ 152 | "name": "User", 153 | "email": "user@gitlab.com" 154 | } 155 | }, 156 | "builds":[ 157 | { 158 | "id": 380, 159 | "stage": "deploy", 160 | "name": "production", 161 | "status": "skipped", 162 | "created_at": "2016-08-12 15:23:28 UTC", 163 | "started_at": null, 164 | "finished_at": null, 165 | "when": "manual", 166 | "manual": true, 167 | "allow_failure": false, 168 | "user":{ 169 | "name": "Administrator", 170 | "username": "root", 171 | "avatar_url": "http://www.gravatar.com/avatar/e32bd13e2add097461cb96824b7a829c?s=80\u0026d=identicon" 172 | }, 173 | "runner": null, 174 | "artifacts_file":{ 175 | "filename": null, 176 | "size": null 177 | } 178 | }, 179 | { 180 | "id": 377, 181 | "stage": "test", 182 | "name": "test-image", 183 | "status": "success", 184 | "created_at": "2016-08-12 15:23:28 UTC", 185 | "started_at": "2016-08-12 15:26:12 UTC", 186 | "finished_at": null, 187 | "when": "on_success", 188 | "manual": false, 189 | "allow_failure": false, 190 | "user":{ 191 | "name": "Administrator", 192 | "username": "root", 193 | "avatar_url": "http://www.gravatar.com/avatar/e32bd13e2add097461cb96824b7a829c?s=80\u0026d=identicon" 194 | }, 195 | "runner": { 196 | "id":380987, 197 | "description":"shared-runners-manager-6.gitlab.com", 198 | "active":true, 199 | "is_shared":true 200 | }, 201 | "artifacts_file":{ 202 | "filename": null, 203 | "size": null 204 | } 205 | }, 206 | { 207 | "id": 378, 208 | "stage": "test", 209 | "name": "test-build", 210 | "status": "success", 211 | "created_at": "2016-08-12 15:23:28 UTC", 212 | "started_at": "2016-08-12 15:26:12 UTC", 213 | "finished_at": "2016-08-12 15:26:29 UTC", 214 | "when": "on_success", 215 | "manual": false, 216 | "allow_failure": false, 217 | "user":{ 218 | "name": "Administrator", 219 | "username": "root", 220 | "avatar_url": "http://www.gravatar.com/avatar/e32bd13e2add097461cb96824b7a829c?s=80\u0026d=identicon" 221 | }, 222 | "runner": { 223 | "id":380987, 224 | "description":"shared-runners-manager-6.gitlab.com", 225 | "active":true, 226 | "is_shared":true 227 | }, 228 | "artifacts_file":{ 229 | "filename": null, 230 | "size": null 231 | } 232 | }, 233 | { 234 | "id": 376, 235 | "stage": "build", 236 | "name": "build-image", 237 | "status": "success", 238 | "created_at": "2016-08-12 15:23:28 UTC", 239 | "started_at": "2016-08-12 15:24:56 UTC", 240 | "finished_at": "2016-08-12 15:25:26 UTC", 241 | "when": "on_success", 242 | "manual": false, 243 | "allow_failure": false, 244 | "user":{ 245 | "name": "Administrator", 246 | "username": "root", 247 | "avatar_url": "http://www.gravatar.com/avatar/e32bd13e2add097461cb96824b7a829c?s=80\u0026d=identicon" 248 | }, 249 | "runner": { 250 | "id":380987, 251 | "description":"shared-runners-manager-6.gitlab.com", 252 | "active":true, 253 | "is_shared":true 254 | }, 255 | "artifacts_file":{ 256 | "filename": null, 257 | "size": null 258 | } 259 | }, 260 | { 261 | "id": 379, 262 | "stage": "deploy", 263 | "name": "staging", 264 | "status": "created", 265 | "created_at": "2016-08-12 15:23:28 UTC", 266 | "started_at": null, 267 | "finished_at": null, 268 | "when": "on_success", 269 | "manual": false, 270 | "allow_failure": false, 271 | "user":{ 272 | "name": "Administrator", 273 | "username": "root", 274 | "avatar_url": "http://www.gravatar.com/avatar/e32bd13e2add097461cb96824b7a829c?s=80\u0026d=identicon" 275 | }, 276 | "runner": null, 277 | "artifacts_file":{ 278 | "filename": null, 279 | "size": null 280 | } 281 | } 282 | ] 283 | }""" 284 | 285 | expect { 286 | source.as[WebhookEvent] == WebhookEvent( 287 | Project(id = 1L), 288 | objectKind = "pipeline" 289 | ).asRight 290 | } 291 | } 292 | } 293 | -------------------------------------------------------------------------------- /gitlab/src/main/scala/io/pg/gitlab/Gitlab.scala: -------------------------------------------------------------------------------- 1 | package io.pg.gitlab 2 | 3 | import scala.util.chaining._ 4 | 5 | import caliban.client.CalibanClientError.DecodingError 6 | import caliban.client.Operations.IsOperation 7 | import caliban.client.SelectionBuilder 8 | import cats.MonadError 9 | import cats.kernel.Eq 10 | import cats.syntax.all._ 11 | import ciris.Secret 12 | import io.odin.Logger 13 | import io.pg.gitlab.Gitlab.MergeRequestInfo 14 | import io.pg.gitlab.GitlabEndpoints.transport.ApprovalRule 15 | import io.pg.gitlab.graphql.MergeRequest 16 | import io.pg.gitlab.graphql.MergeRequestConnection 17 | import io.pg.gitlab.graphql.MergeRequestState 18 | import io.pg.gitlab.graphql.Pipeline 19 | import io.pg.gitlab.graphql.PipelineStatusEnum 20 | import io.pg.gitlab.graphql.Project 21 | import io.pg.gitlab.graphql.ProjectConnection 22 | import io.pg.gitlab.graphql.Query 23 | import io.pg.gitlab.graphql.UserCore 24 | import sttp.client3.Request 25 | import sttp.client3.SttpBackend 26 | import sttp.model.Uri 27 | import sttp.tapir.Endpoint 28 | import sttp.tapir.generic.auto._ 29 | import sttp.tapir.json.circe._ 30 | import fs2.Stream 31 | import io.circe.{Codec => CirceCodec} 32 | import io.pg.gitlab.GitlabEndpoints.transport.MergeRequestApprovals 33 | import monocle.syntax.all._ 34 | import cats.Show 35 | import io.pg.TextUtils 36 | import cats.MonadThrow 37 | 38 | trait Gitlab[F[_]] { 39 | def mergeRequests(projectId: Long): F[List[MergeRequestInfo]] 40 | def acceptMergeRequest(projectId: Long, mergeRequestIid: Long): F[Unit] 41 | def rebaseMergeRequest(projectId: Long, mergeRequestIid: Long): F[Unit] 42 | def forceApprove(projectId: Long, mergeRequestIid: Long): F[Unit] 43 | } 44 | 45 | object Gitlab { 46 | 47 | def apply[F[_]](using F: Gitlab[F]): Gitlab[F] = F 48 | 49 | // VCS-specific MR information 50 | // Not specific to the method of fetching (no graphql model references etc.) 51 | // Fields only required according to reason (e.g. must have a numeric ID - we might loosen this later) 52 | final case class MergeRequestInfo( 53 | projectId: Long, 54 | mergeRequestIid: Long, 55 | status: Option[MergeRequestInfo.Status], 56 | authorUsername: String, 57 | description: Option[String], 58 | needsRebase: Boolean, 59 | hasConflicts: Boolean 60 | ) 61 | 62 | object MergeRequestInfo { 63 | 64 | enum Status { 65 | case Success 66 | case Other(value: String) 67 | 68 | implicit val eq: Eq[Status] = Eq.fromUniversalEquals 69 | } 70 | 71 | implicit val showTrimmed: Show[MergeRequestInfo] = 72 | _.focus(_.description).modify(_.map(TextUtils.inline).map(TextUtils.trim(maxChars = 30))).toString 73 | } 74 | 75 | def sttpInstance[F[_]: Logger: MonadThrow]( 76 | baseUri: Uri, 77 | accessToken: Secret[String] 78 | )( 79 | implicit backend: SttpBackend[F, Any], 80 | SC: fs2.Compiler[F, F] 81 | ): Gitlab[F] = { 82 | 83 | def runRequest[O](request: Request[O, Any]): F[O] = 84 | // todo multiple possible header names... 85 | request.header("Private-Token", accessToken.value).send(backend).map(_.body) 86 | 87 | import sttp.tapir.client.sttp._ 88 | 89 | def runEndpoint[I, E, O]( 90 | endpoint: Endpoint[I, E, O, Any] 91 | ): I => F[Either[E, O]] = 92 | i => runRequest(SttpClientInterpreter.toRequestThrowDecodeFailures(endpoint, baseUri.some).apply(i)) 93 | 94 | def runInfallibleEndpoint[I, O]( 95 | endpoint: Endpoint[I, Nothing, O, Any] 96 | ): I => F[O] = 97 | runEndpoint[I, Nothing, O](endpoint).nested.map(_.merge).value 98 | 99 | def runGraphQLQuery[A: IsOperation, B](a: SelectionBuilder[A, B]): F[B] = 100 | runRequest(a.toRequest(baseUri.addPath("api", "graphql"))).rethrow 101 | 102 | new Gitlab[F] { 103 | def mergeRequests(projectId: Long): F[List[MergeRequestInfo]] = 104 | Logger[F].info( 105 | "Finding merge requests", 106 | Map( 107 | "projectId" -> projectId.show 108 | ) 109 | ) *> Query 110 | .projects(ids = List(show"gid://gitlab/Project/$projectId").some)( 111 | ProjectConnection 112 | .nodes( 113 | Project 114 | .mergeRequests( 115 | state = MergeRequestState.opened.some 116 | )( 117 | MergeRequestConnection 118 | .nodes(mergeRequestInfoSelection(projectId)) 119 | ) 120 | ) 121 | .map(flattenTheEarth) 122 | ) 123 | .mapEither(_.toRight(DecodingError("Project not found"))) 124 | .pipe(runGraphQLQuery(_)) 125 | .flatTap { result => 126 | Logger[F].info( 127 | "Found merge requests", 128 | Map("result" -> result.show.mkString, "size" -> result.size.show) 129 | ) 130 | } 131 | 132 | private def flattenTheEarth[A]: Option[List[Option[Option[Option[List[Option[A]]]]]]] => List[A] = 133 | _.toList.flatten.flatten.flatten.flatten.flatten.flatten 134 | 135 | private def mergeRequestInfoSelection(projectId: Long): SelectionBuilder[MergeRequest, MergeRequestInfo] = ( 136 | MergeRequest.iid.mapEither(_.toLongOption.toRight(DecodingError("MR IID wasn't a Long"))) ~ 137 | MergeRequest.headPipeline(Pipeline.status.map(convertPipelineStatus)) ~ 138 | MergeRequest 139 | .author(UserCore.username) 140 | .mapEither(_.toRight(DecodingError("MR has no author"))) ~ 141 | MergeRequest.description ~ 142 | MergeRequest.shouldBeRebased ~ 143 | MergeRequest.conflicts 144 | ).mapN(buildMergeRequest(projectId) _) 145 | 146 | private def buildMergeRequest( 147 | projectId: Long 148 | )( 149 | mergeRequestIid: Long, 150 | status: Option[MergeRequestInfo.Status], 151 | authorUsername: String, 152 | description: Option[String], 153 | needsRebase: Boolean, 154 | hasConflicts: Boolean 155 | ): MergeRequestInfo = MergeRequestInfo( 156 | projectId = projectId, 157 | mergeRequestIid = mergeRequestIid, 158 | status = status, 159 | authorUsername = authorUsername, 160 | description = description, 161 | needsRebase = needsRebase, 162 | hasConflicts = hasConflicts 163 | ) 164 | 165 | private val convertPipelineStatus: PipelineStatusEnum => MergeRequestInfo.Status = { 166 | case PipelineStatusEnum.SUCCESS => MergeRequestInfo.Status.Success 167 | case other => MergeRequestInfo.Status.Other(other.toString) 168 | } 169 | 170 | def acceptMergeRequest(projectId: Long, mergeRequestIid: Long): F[Unit] = 171 | runInfallibleEndpoint(GitlabEndpoints.acceptMergeRequest) 172 | .apply((projectId, mergeRequestIid)) 173 | .void 174 | 175 | def rebaseMergeRequest(projectId: Long, mergeRequestIid: Long): F[Unit] = 176 | runInfallibleEndpoint(GitlabEndpoints.rebaseMergeRequest) 177 | .apply((projectId, mergeRequestIid)) 178 | .void 179 | 180 | private def listMRApprovalRules(projectId: Long, mergeRequestIid: Long): F[List[ApprovalRule]] = 181 | runInfallibleEndpoint(GitlabEndpoints.listMRApprovaRules) 182 | .apply((projectId, mergeRequestIid)) 183 | 184 | private def setMrRuleApprovals(projectId: Long, mergeRequestIid: Long, ruleId: Long, amount: Int): F[Unit] = 185 | runInfallibleEndpoint(GitlabEndpoints.setMRRuleApprovalRequirement) 186 | .apply((projectId, mergeRequestIid, ruleId, amount)) 187 | 188 | private def getMrApprovals(projectId: Long, mergeRequestIid: Long): F[MergeRequestApprovals] = 189 | runInfallibleEndpoint(GitlabEndpoints.getMergeRequestApprovals) 190 | .apply((projectId, mergeRequestIid)) 191 | 192 | private def setMrApprovals(projectId: Long, mergeRequestIid: Long, amount: Int): F[Unit] = 193 | runInfallibleEndpoint(GitlabEndpoints.setMergeRequestApprovals) 194 | .apply((projectId, mergeRequestIid, amount)) 195 | 196 | def forceApprove(projectId: Long, mergeRequestIid: Long): F[Unit] = { 197 | val clearDirectApprovals = Stream 198 | .eval(getMrApprovals(projectId, mergeRequestIid)) 199 | .filter(_.approvalsRequired > 0) 200 | .evalMap { _ => 201 | setMrApprovals(projectId, mergeRequestIid, 0) 202 | } 203 | 204 | val removeMutableApprovalRules = Stream 205 | .evals(listMRApprovalRules(projectId, mergeRequestIid)) 206 | .filter(_.isMutable) 207 | .evalMap { rule => 208 | setMrRuleApprovals(projectId, mergeRequestIid, rule.id, 0) 209 | } 210 | 211 | clearDirectApprovals ++ 212 | removeMutableApprovalRules 213 | } 214 | .compile 215 | .drain 216 | } 217 | } 218 | 219 | final case class GitlabError(msg: String) extends Throwable(s"Gitlab error: $msg") 220 | } 221 | 222 | object GitlabEndpoints { 223 | import sttp.tapir._ 224 | 225 | private val baseEndpoint = infallibleEndpoint.in("api" / "v4") 226 | 227 | val acceptMergeRequest: Endpoint[(Long, Long), Nothing, Unit, Any] = 228 | baseEndpoint 229 | .put 230 | .in("projects" / path[Long]("projectId")) 231 | .in("merge_requests" / path[Long]("merge_request_iid")) 232 | .in("merge") 233 | 234 | val rebaseMergeRequest: Endpoint[(Long, Long), Nothing, Unit, Any] = 235 | baseEndpoint 236 | .put 237 | .in("projects" / path[Long]("projectId")) 238 | .in("merge_requests" / path[Long]("merge_request_iid")) 239 | .in("rebase") 240 | 241 | // Legacy methods, still in use though 242 | val getMergeRequestApprovals: Endpoint[(Long, Long), Nothing, MergeRequestApprovals, Any] = 243 | baseEndpoint 244 | .get 245 | .in("projects" / path[Long]("projectId")) 246 | .in("merge_requests" / path[Long]("merge_request_iid")) 247 | .in("approvals") 248 | .out(jsonBody[MergeRequestApprovals]) 249 | 250 | val setMergeRequestApprovals: Endpoint[(Long, Long, Int), Nothing, Unit, Any] = 251 | baseEndpoint 252 | .post 253 | .in("projects" / path[Long]("projectId")) 254 | .in("merge_requests" / path[Long]("merge_request_iid")) 255 | .in("approvals") 256 | .in(query[Int]("approvals_required")) 257 | 258 | val listMRApprovaRules: Endpoint[(Long, Long), Nothing, List[ApprovalRule], Any] = 259 | baseEndpoint 260 | .get 261 | .in("projects" / path[Long]("projectId")) 262 | .in("merge_requests" / path[Long]("merge_request_iid")) 263 | .in("approval_rules") 264 | .out( 265 | jsonBody[List[ApprovalRule]] 266 | ) 267 | 268 | val setMRRuleApprovalRequirement: Endpoint[(Long, Long, Long, Int), Nothing, Unit, Any] = 269 | baseEndpoint 270 | .put 271 | .in("projects" / path[Long]("projectId")) 272 | .in("merge_requests" / path[Long]("merge_request_iid")) 273 | .in("approval_rules" / path[Long]("approval_rule_id")) 274 | .in(query[Int]("approvals_required")) 275 | 276 | object transport { 277 | 278 | final case class ApprovalRule(id: Long, name: String, ruleType: String) { 279 | val isMutable: Boolean = ruleType != "code_owner" 280 | } 281 | 282 | object ApprovalRule { 283 | // todo: use configured codec when https://github.com/circe/circe/pull/1800 is available 284 | given CirceCodec[ApprovalRule] = CirceCodec.forProduct3("id", "name", "rule_type")(apply)(r => (r.id, r.name, r.ruleType)) 285 | } 286 | 287 | final case class MergeRequestApprovals(approvalsRequired: Int) 288 | 289 | object MergeRequestApprovals { 290 | // todo: use configured codec when https://github.com/circe/circe/pull/1800 is available 291 | given CirceCodec[MergeRequestApprovals] = CirceCodec.forProduct1("approvals_required")(apply)(_.approvalsRequired) 292 | } 293 | 294 | } 295 | 296 | } 297 | -------------------------------------------------------------------------------- /bootstrap/src/main/resources/reflect-config.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "byte[]" 4 | }, 5 | { 6 | "name": "cats.Later", 7 | "fields": [ 8 | { 9 | "name": "0bitmap$1", 10 | "allowUnsafeAccess": true 11 | } 12 | ] 13 | }, 14 | { 15 | "name": "cats.effect.unsafe.IORuntimeCompanionPlatform", 16 | "fields": [ 17 | { 18 | "name": "0bitmap$1", 19 | "allowUnsafeAccess": true 20 | } 21 | ] 22 | }, 23 | { 24 | "name": "cats.effect.unsafe.WorkStealingThreadPool", 25 | "fields": [ 26 | { 27 | "name": "0bitmap$1", 28 | "allowUnsafeAccess": true 29 | } 30 | ] 31 | }, 32 | { 33 | "name": "com.sun.crypto.provider.AESCipher$General", 34 | "methods": [ 35 | { 36 | "name": "", 37 | "parameterTypes": [] 38 | } 39 | ] 40 | }, 41 | { 42 | "name": "com.sun.crypto.provider.DHParameters", 43 | "methods": [ 44 | { 45 | "name": "", 46 | "parameterTypes": [] 47 | } 48 | ] 49 | }, 50 | { 51 | "name": "com.sun.crypto.provider.HmacCore$HmacSHA256", 52 | "methods": [ 53 | { 54 | "name": "", 55 | "parameterTypes": [] 56 | } 57 | ] 58 | }, 59 | { 60 | "name": "com.sun.crypto.provider.TlsMasterSecretGenerator", 61 | "methods": [ 62 | { 63 | "name": "", 64 | "parameterTypes": [] 65 | } 66 | ] 67 | }, 68 | { 69 | "name": "io.circe.Decoder$", 70 | "fields": [ 71 | { 72 | "name": "0bitmap$1", 73 | "allowUnsafeAccess": true 74 | } 75 | ] 76 | }, 77 | { 78 | "name": "io.circe.Encoder$", 79 | "fields": [ 80 | { 81 | "name": "0bitmap$1", 82 | "allowUnsafeAccess": true 83 | } 84 | ] 85 | }, 86 | { 87 | "name": "java.lang.String" 88 | }, 89 | { 90 | "name": "java.lang.String[]" 91 | }, 92 | { 93 | "name": "java.lang.invoke.VarHandle", 94 | "methods": [ 95 | { 96 | "name": "releaseFence", 97 | "parameterTypes": [] 98 | } 99 | ] 100 | }, 101 | { 102 | "name": "java.security.AlgorithmParametersSpi" 103 | }, 104 | { 105 | "name": "java.security.KeyStoreSpi" 106 | }, 107 | { 108 | "name": "java.security.MessageDigestSpi" 109 | }, 110 | { 111 | "name": "java.security.SecureRandomParameters" 112 | }, 113 | { 114 | "name": "java.security.interfaces.RSAPrivateKey" 115 | }, 116 | { 117 | "name": "java.security.interfaces.RSAPublicKey" 118 | }, 119 | { 120 | "name": "java.util.Date" 121 | }, 122 | { 123 | "name": "javax.security.auth.x500.X500Principal", 124 | "fields": [ 125 | { 126 | "name": "thisX500Name" 127 | } 128 | ], 129 | "methods": [ 130 | { 131 | "name": "", 132 | "parameterTypes": [ 133 | "sun.security.x509.X500Name" 134 | ] 135 | } 136 | ] 137 | }, 138 | { 139 | "name": "org.polyvariant.Gitlab$$anon$2", 140 | "fields": [ 141 | { 142 | "name": "0bitmap$1", 143 | "allowUnsafeAccess": true 144 | } 145 | ] 146 | }, 147 | { 148 | "name": "org.polyvariant.Gitlab$Webhook$", 149 | "fields": [ 150 | { 151 | "name": "0bitmap$2", 152 | "allowUnsafeAccess": true 153 | } 154 | ] 155 | }, 156 | { 157 | "name": "sttp.model.Header$", 158 | "fields": [ 159 | { 160 | "name": "0bitmap$1", 161 | "allowUnsafeAccess": true 162 | } 163 | ] 164 | }, 165 | { 166 | "name": "sun.misc.Unsafe", 167 | "allDeclaredFields": true 168 | }, 169 | { 170 | "name": "sun.security.pkcs.SignerInfo[]" 171 | }, 172 | { 173 | "name": "sun.security.pkcs12.PKCS12KeyStore", 174 | "methods": [ 175 | { 176 | "name": "", 177 | "parameterTypes": [] 178 | } 179 | ] 180 | }, 181 | { 182 | "name": "sun.security.pkcs12.PKCS12KeyStore$DualFormatPKCS12", 183 | "methods": [ 184 | { 185 | "name": "", 186 | "parameterTypes": [] 187 | } 188 | ] 189 | }, 190 | { 191 | "name": "sun.security.provider.DSA$SHA224withDSA", 192 | "methods": [ 193 | { 194 | "name": "", 195 | "parameterTypes": [] 196 | } 197 | ] 198 | }, 199 | { 200 | "name": "sun.security.provider.DSA$SHA256withDSA", 201 | "methods": [ 202 | { 203 | "name": "", 204 | "parameterTypes": [] 205 | } 206 | ] 207 | }, 208 | { 209 | "name": "sun.security.provider.JavaKeyStore$DualFormatJKS", 210 | "methods": [ 211 | { 212 | "name": "", 213 | "parameterTypes": [] 214 | } 215 | ] 216 | }, 217 | { 218 | "name": "sun.security.provider.JavaKeyStore$JKS", 219 | "methods": [ 220 | { 221 | "name": "", 222 | "parameterTypes": [] 223 | } 224 | ] 225 | }, 226 | { 227 | "name": "sun.security.provider.NativePRNG", 228 | "methods": [ 229 | { 230 | "name": "", 231 | "parameterTypes": [] 232 | } 233 | ] 234 | }, 235 | { 236 | "name": "sun.security.provider.SHA", 237 | "methods": [ 238 | { 239 | "name": "", 240 | "parameterTypes": [] 241 | } 242 | ] 243 | }, 244 | { 245 | "name": "sun.security.provider.SHA2$SHA224", 246 | "methods": [ 247 | { 248 | "name": "", 249 | "parameterTypes": [] 250 | } 251 | ] 252 | }, 253 | { 254 | "name": "sun.security.provider.SHA2$SHA256", 255 | "methods": [ 256 | { 257 | "name": "", 258 | "parameterTypes": [] 259 | } 260 | ] 261 | }, 262 | { 263 | "name": "sun.security.provider.SHA5$SHA384", 264 | "methods": [ 265 | { 266 | "name": "", 267 | "parameterTypes": [] 268 | } 269 | ] 270 | }, 271 | { 272 | "name": "sun.security.provider.SHA5$SHA512", 273 | "methods": [ 274 | { 275 | "name": "", 276 | "parameterTypes": [] 277 | } 278 | ] 279 | }, 280 | { 281 | "name": "sun.security.provider.X509Factory", 282 | "methods": [ 283 | { 284 | "name": "", 285 | "parameterTypes": [] 286 | } 287 | ] 288 | }, 289 | { 290 | "name": "sun.security.provider.certpath.PKIXCertPathValidator", 291 | "methods": [ 292 | { 293 | "name": "", 294 | "parameterTypes": [] 295 | } 296 | ] 297 | }, 298 | { 299 | "name": "sun.security.rsa.RSAKeyFactory$Legacy", 300 | "methods": [ 301 | { 302 | "name": "", 303 | "parameterTypes": [] 304 | } 305 | ] 306 | }, 307 | { 308 | "name": "sun.security.rsa.RSAPSSSignature", 309 | "methods": [ 310 | { 311 | "name": "", 312 | "parameterTypes": [] 313 | } 314 | ] 315 | }, 316 | { 317 | "name": "sun.security.rsa.RSASignature$SHA224withRSA", 318 | "methods": [ 319 | { 320 | "name": "", 321 | "parameterTypes": [] 322 | } 323 | ] 324 | }, 325 | { 326 | "name": "sun.security.rsa.RSASignature$SHA256withRSA", 327 | "methods": [ 328 | { 329 | "name": "", 330 | "parameterTypes": [] 331 | } 332 | ] 333 | }, 334 | { 335 | "name": "sun.security.rsa.RSASignature$SHA384withRSA", 336 | "methods": [ 337 | { 338 | "name": "", 339 | "parameterTypes": [] 340 | } 341 | ] 342 | }, 343 | { 344 | "name": "sun.security.ssl.KeyManagerFactoryImpl$SunX509", 345 | "methods": [ 346 | { 347 | "name": "", 348 | "parameterTypes": [] 349 | } 350 | ] 351 | }, 352 | { 353 | "name": "sun.security.ssl.SSLContextImpl$DefaultSSLContext", 354 | "methods": [ 355 | { 356 | "name": "", 357 | "parameterTypes": [] 358 | } 359 | ] 360 | }, 361 | { 362 | "name": "sun.security.ssl.TrustManagerFactoryImpl$PKIXFactory", 363 | "methods": [ 364 | { 365 | "name": "", 366 | "parameterTypes": [] 367 | } 368 | ] 369 | }, 370 | { 371 | "name": "sun.security.util.ObjectIdentifier" 372 | }, 373 | { 374 | "name": "sun.security.x509.AuthorityInfoAccessExtension", 375 | "methods": [ 376 | { 377 | "name": "", 378 | "parameterTypes": [ 379 | "java.lang.Boolean", 380 | "java.lang.Object" 381 | ] 382 | } 383 | ] 384 | }, 385 | { 386 | "name": "sun.security.x509.AuthorityKeyIdentifierExtension", 387 | "methods": [ 388 | { 389 | "name": "", 390 | "parameterTypes": [ 391 | "java.lang.Boolean", 392 | "java.lang.Object" 393 | ] 394 | } 395 | ] 396 | }, 397 | { 398 | "name": "sun.security.x509.BasicConstraintsExtension", 399 | "methods": [ 400 | { 401 | "name": "", 402 | "parameterTypes": [ 403 | "java.lang.Boolean", 404 | "java.lang.Object" 405 | ] 406 | } 407 | ] 408 | }, 409 | { 410 | "name": "sun.security.x509.CRLDistributionPointsExtension", 411 | "methods": [ 412 | { 413 | "name": "", 414 | "parameterTypes": [ 415 | "java.lang.Boolean", 416 | "java.lang.Object" 417 | ] 418 | } 419 | ] 420 | }, 421 | { 422 | "name": "sun.security.x509.CertificateExtensions" 423 | }, 424 | { 425 | "name": "sun.security.x509.CertificatePoliciesExtension", 426 | "methods": [ 427 | { 428 | "name": "", 429 | "parameterTypes": [ 430 | "java.lang.Boolean", 431 | "java.lang.Object" 432 | ] 433 | } 434 | ] 435 | }, 436 | { 437 | "name": "sun.security.x509.ExtendedKeyUsageExtension", 438 | "methods": [ 439 | { 440 | "name": "", 441 | "parameterTypes": [ 442 | "java.lang.Boolean", 443 | "java.lang.Object" 444 | ] 445 | } 446 | ] 447 | }, 448 | { 449 | "name": "sun.security.x509.IssuerAlternativeNameExtension", 450 | "methods": [ 451 | { 452 | "name": "", 453 | "parameterTypes": [ 454 | "java.lang.Boolean", 455 | "java.lang.Object" 456 | ] 457 | } 458 | ] 459 | }, 460 | { 461 | "name": "sun.security.x509.KeyUsageExtension", 462 | "methods": [ 463 | { 464 | "name": "", 465 | "parameterTypes": [ 466 | "java.lang.Boolean", 467 | "java.lang.Object" 468 | ] 469 | } 470 | ] 471 | }, 472 | { 473 | "name": "sun.security.x509.NetscapeCertTypeExtension", 474 | "methods": [ 475 | { 476 | "name": "", 477 | "parameterTypes": [ 478 | "java.lang.Boolean", 479 | "java.lang.Object" 480 | ] 481 | } 482 | ] 483 | }, 484 | { 485 | "name": "sun.security.x509.PrivateKeyUsageExtension", 486 | "methods": [ 487 | { 488 | "name": "", 489 | "parameterTypes": [ 490 | "java.lang.Boolean", 491 | "java.lang.Object" 492 | ] 493 | } 494 | ] 495 | }, 496 | { 497 | "name": "sun.security.x509.SubjectAlternativeNameExtension", 498 | "methods": [ 499 | { 500 | "name": "", 501 | "parameterTypes": [ 502 | "java.lang.Boolean", 503 | "java.lang.Object" 504 | ] 505 | } 506 | ] 507 | }, 508 | { 509 | "name": "sun.security.x509.SubjectKeyIdentifierExtension", 510 | "methods": [ 511 | { 512 | "name": "", 513 | "parameterTypes": [ 514 | "java.lang.Boolean", 515 | "java.lang.Object" 516 | ] 517 | } 518 | ] 519 | } 520 | ] --------------------------------------------------------------------------------