From 995fefb1cb7746eaa5644b73f0e743c85f751e3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Koz=C5=82owski?= Date: Sat, 21 May 2022 18:19:38 +0200 Subject: [PATCH 1/7] WIP: Scala 3 update --- .scalafmt.conf | 2 +- build.sbt | 36 +++++++------- core/src/main/scala/io/pg/Prelude.scala | 9 ---- .../scala/io/pg/messaging/messaging.scala | 12 +++-- .../src/main/scala/io/pg/gitlab/Gitlab.scala | 26 +++++----- .../scala/io/pg/gitlab/webhook/webhook.scala | 21 ++------- src/main/scala/io/pg/Application.scala | 4 +- src/main/scala/io/pg/Main.scala | 12 ++--- src/main/scala/io/pg/MergeRequests.scala | 4 +- src/main/scala/io/pg/actions.scala | 10 ++-- src/main/scala/io/pg/appconfig.scala | 6 +-- .../scala/io/pg/config/ProjectConfig.scala | 3 +- src/main/scala/io/pg/config/format.scala | 47 +++++++++---------- src/main/scala/io/pg/resolver.scala | 39 +++++++-------- .../scala/io/pg/transport/transport.scala | 30 +++--------- src/main/scala/io/pg/webhook/webhook.scala | 31 ++++++++++-- .../scala/io/pg/WebhookProcessorTest.scala | 13 +++-- .../io/pg/fakes/ProjectActionsStateFake.scala | 17 ++++--- .../io/pg/fakes/ProjectConfigReaderFake.scala | 5 +- 19 files changed, 152 insertions(+), 175 deletions(-) delete mode 100644 core/src/main/scala/io/pg/Prelude.scala diff --git a/.scalafmt.conf b/.scalafmt.conf index 8a7fee9f..2d60410e 100644 --- a/.scalafmt.conf +++ b/.scalafmt.conf @@ -1,4 +1,4 @@ -runner.dialect=scala213 +runner.dialect=scala3 version = "3.5.3" maxColumn = 140 diff --git a/build.sbt b/build.sbt index 9a695646..dab07507 100644 --- a/build.sbt +++ b/build.sbt @@ -2,6 +2,8 @@ import com.typesafe.sbt.packager.docker.Cmd import com.typesafe.sbt.packager.docker.ExecCmd +Global / onChangedBuildSource := ReloadOnSourceChanges + inThisBuild( List( organization := "io.pg", @@ -22,9 +24,9 @@ inThisBuild( val GraalVM11 = "graalvm-ce-java11@20.1.0" -val Scala213 = "2.13.6" -ThisBuild / scalaVersion := Scala213 -ThisBuild / crossScalaVersions := Seq(Scala213) +val Scala3 = "3.1.1" +ThisBuild / scalaVersion := Scala3 +ThisBuild / crossScalaVersions := Seq(Scala3) ThisBuild / githubWorkflowJavaVersions := Seq(GraalVM11) ThisBuild / githubWorkflowPublishTargetBranches := Seq( @@ -67,23 +69,22 @@ def crossPlugin(x: sbt.librarymanagement.ModuleID) = compilerPlugin(x.cross(CrossVersion.full)) val compilerPlugins = List( - crossPlugin("org.typelevel" % "kind-projector" % "0.13.2"), - crossPlugin("com.github.cb372" % "scala-typed-holes" % "0.1.11"), - crossPlugin("org.polyvariant" % "better-tostring" % "0.3.15"), - compilerPlugin("com.olegpy" %% "better-monadic-for" % "0.3.1") + // crossPlugin("org.typelevel" % "kind-projector" % "0.13.2"), + // crossPlugin("com.github.cb372" % "scala-typed-holes" % "0.1.11"), + crossPlugin("org.polyvariant" % "better-tostring" % "0.3.15") + // compilerPlugin("com.olegpy" %% "better-monadic-for" % "0.3.1") ) val commonSettings = List( scalacOptions --= List("-Xfatal-warnings"), - scalacOptions += "-Ymacro-annotations", libraryDependencies ++= List( "org.typelevel" %% "cats-core" % "2.7.0", "org.typelevel" %% "cats-effect" % "3.3.12", - "org.typelevel" %% "cats-tagless-macros" % "0.14.0", + // "org.typelevel" %% "cats-tagless-macros" % "0.14.0", "co.fs2" %% "fs2-core" % "3.2.7", "com.github.valskalla" %% "odin-core" % "0.13.0", - "io.circe" %% "circe-core" % "0.14.1", - "dev.optics" %% "monocle-macro" % "3.1.0", + "io.circe" %% "circe-core" % "0.14.2", + "dev.optics" %% "monocle-core" % "3.1.0", "com.disneystreaming" %% "weaver-cats" % "0.7.11" % Test, "com.disneystreaming" %% "weaver-scalacheck" % "0.7.11" % Test ) ++ compilerPlugins, @@ -97,9 +98,9 @@ lazy val gitlab = project libraryDependencies ++= List( "is.cir" %% "ciris" % "2.3.2", "com.kubukoz" %% "caliban-gitlab" % "0.1.0", - "io.circe" %% "circe-generic-extras" % "0.14.1", - "io.circe" %% "circe-parser" % "0.14.1" % Test, - "io.circe" %% "circe-literal" % "0.14.1" % Test, + // "io.circe" %% "circe-generic-extras" % "0.14.2", + "io.circe" %% "circe-parser" % "0.14.2" % Test, + "io.circe" %% "circe-literal" % "0.14.2" % Test, "com.softwaremill.sttp.tapir" %% "tapir-core" % "0.18.0-M17", "com.softwaremill.sttp.tapir" %% "tapir-json-circe" % "0.18.0-M17", "com.softwaremill.sttp.tapir" %% "tapir-sttp-client" % "0.18.0-M17" @@ -109,14 +110,14 @@ lazy val gitlab = project lazy val bootstrap = project .settings( - scalaVersion := "3.0.0", + scalaVersion := "3.1.1", libraryDependencies ++= List( "org.typelevel" %% "cats-core" % "2.7.0", "org.typelevel" %% "cats-effect" % "3.3.12", "com.kubukoz" %% "caliban-gitlab" % "0.1.0", "com.softwaremill.sttp.client3" %% "core" % "3.3.15", "com.softwaremill.sttp.client3" %% "circe" % "3.3.15", - "io.circe" %% "circe-core" % "0.14.1", + "io.circe" %% "circe-core" % "0.14.2", crossPlugin("org.polyvariant" % "better-tostring" % "0.3.15") ), publish / skip := true, @@ -195,8 +196,7 @@ lazy val pitgull = "org.http4s" %% "http4s-blaze-server" % "0.23.11", "org.http4s" %% "http4s-blaze-client" % "0.23.11", "is.cir" %% "ciris" % "2.3.2", - "io.circe" %% "circe-generic-extras" % "0.14.0", - "io.scalaland" %% "chimney" % "0.6.1", + // "io.circe" %% "circe-generic-extras" % "0.14.0", "io.chrisdavenport" %% "cats-time" % "0.4.0", "com.github.valskalla" %% "odin-core" % "0.13.0", "com.github.valskalla" %% "odin-slf4j" % "0.13.0", diff --git a/core/src/main/scala/io/pg/Prelude.scala b/core/src/main/scala/io/pg/Prelude.scala deleted file mode 100644 index 439a31e4..00000000 --- a/core/src/main/scala/io/pg/Prelude.scala +++ /dev/null @@ -1,9 +0,0 @@ -package io.pg - -object Prelude { - - implicit class AnythingAnything[A](private val a: A) extends AnyVal { - def ??? : Nothing = ??? - } - -} diff --git a/core/src/main/scala/io/pg/messaging/messaging.scala b/core/src/main/scala/io/pg/messaging/messaging.scala index e9750917..0eafaef5 100644 --- a/core/src/main/scala/io/pg/messaging/messaging.scala +++ b/core/src/main/scala/io/pg/messaging/messaging.scala @@ -2,11 +2,12 @@ package io.pg.messaging import cats.effect.std.Queue import scala.reflect.ClassTag -import cats.tagless.autoInvariant import cats.syntax.all._ import cats.ApplicativeError import io.odin.Logger import cats.Functor +import cats.Invariant +import cats.ApplicativeThrow trait Publisher[F[_], -A] { def publish(a: A): F[Unit] @@ -16,7 +17,7 @@ final case class Processor[F[_], -A](process: fs2.Pipe[F, A, Unit]) object Processor { - def simple[F[_]: ApplicativeError[*[_], Throwable]: Logger, A]( + def simple[F[_]: ApplicativeThrow: Logger, A]( f: A => F[Unit] ): Processor[F, A] = Processor[F, A] { @@ -35,12 +36,17 @@ object Processor { } -@autoInvariant trait Channel[F[_], A] extends Publisher[F, A] { self => def consume: fs2.Stream[F, A] } object Channel { + given[F[_]]: Invariant[Channel[F, *]] with { + def imap[A, B](chan: Channel[F, A])(f: A => B)(g: B => A): Channel[F, B] = new { + def consume: fs2.Stream[F,B] = chan.consume.map(f) + def publish(b: B): F[Unit] = chan.publish(g(b)) + } + } def fromQueue[F[_]: Functor, A](q: Queue[F, A]): Channel[F, A] = new Channel[F, A] { diff --git a/gitlab/src/main/scala/io/pg/gitlab/Gitlab.scala b/gitlab/src/main/scala/io/pg/gitlab/Gitlab.scala index d9d3b3b4..1bafe5bc 100644 --- a/gitlab/src/main/scala/io/pg/gitlab/Gitlab.scala +++ b/gitlab/src/main/scala/io/pg/gitlab/Gitlab.scala @@ -8,7 +8,6 @@ import caliban.client.SelectionBuilder import cats.MonadError import cats.kernel.Eq import cats.syntax.all._ -import cats.tagless.finalAlg import ciris.Secret import io.odin.Logger import io.pg.gitlab.Gitlab.MergeRequestInfo @@ -30,14 +29,12 @@ import sttp.tapir.generic.auto._ import sttp.tapir.json.circe._ import fs2.Stream import io.circe.{Codec => CirceCodec} -import io.circe.generic.extras.semiauto._ -import io.circe.generic.extras.Configuration import io.pg.gitlab.GitlabEndpoints.transport.MergeRequestApprovals -import monocle.macros.Lenses +import monocle.syntax.all._ import cats.Show import io.pg.TextUtils +import cats.MonadThrow -@finalAlg trait Gitlab[F[_]] { def mergeRequests(projectId: Long): F[List[MergeRequestInfo]] def acceptMergeRequest(projectId: Long, mergeRequestIid: Long): F[Unit] @@ -47,10 +44,11 @@ trait Gitlab[F[_]] { object Gitlab { + def apply[F[_]](using F: Gitlab[F]): Gitlab[F] = F + // VCS-specific MR information // Not specific to the method of fetching (no graphql model references etc.) // Fields only required according to reason (e.g. must have a numeric ID - we might loosen this later) - @Lenses final case class MergeRequestInfo( projectId: Long, mergeRequestIid: Long, @@ -62,20 +60,19 @@ object Gitlab { ) object MergeRequestInfo { - sealed trait Status extends Product with Serializable - object Status { - case object Success extends Status - final case class Other(value: String) extends Status + enum Status { + case Success + case Other(value: String) implicit val eq: Eq[Status] = Eq.fromUniversalEquals } implicit val showTrimmed: Show[MergeRequestInfo] = - MergeRequestInfo.description.modify(_.map(TextUtils.inline).map(TextUtils.trim(maxChars = 30))).apply(_).toString + _.focus(_.description).modify(_.map(TextUtils.inline).map(TextUtils.trim(maxChars = 30))).toString } - def sttpInstance[F[_]: Logger: MonadError[*[_], Throwable]]( + def sttpInstance[F[_]: Logger: MonadThrow]( baseUri: Uri, accessToken: Secret[String] )( @@ -278,20 +275,19 @@ object GitlabEndpoints { .in(query[Int]("approvals_required")) object transport { - implicit val config: Configuration = Configuration.default.withSnakeCaseMemberNames final case class ApprovalRule(id: Long, name: String, ruleType: String) { val isMutable: Boolean = ruleType != "code_owner" } object ApprovalRule { - implicit val codec: CirceCodec[ApprovalRule] = deriveConfiguredCodec + given CirceCodec[ApprovalRule] = CirceCodec.forProduct3("id", "name", "rule_type")(apply)(r => (r.id, r.name, r.ruleType)) } final case class MergeRequestApprovals(approvalsRequired: Int) object MergeRequestApprovals { - implicit val codec: CirceCodec[MergeRequestApprovals] = deriveConfiguredCodec + given CirceCodec[MergeRequestApprovals] = CirceCodec.forProduct1("approvals_required")(apply)(_.approvalsRequired) } } diff --git a/gitlab/src/main/scala/io/pg/gitlab/webhook/webhook.scala b/gitlab/src/main/scala/io/pg/gitlab/webhook/webhook.scala index e7d8c4c4..63cbe4ed 100644 --- a/gitlab/src/main/scala/io/pg/gitlab/webhook/webhook.scala +++ b/gitlab/src/main/scala/io/pg/gitlab/webhook/webhook.scala @@ -1,27 +1,16 @@ package io.pg.gitlab.webhook -import io.circe.generic.extras._ +import io.circe.Codec -private object CirceConfiguration { - - implicit val config: Configuration = - Configuration - .default - .withSnakeCaseMemberNames - .withSnakeCaseConstructorNames - .withDiscriminator("object_kind") +final case class WebhookEvent(project: Project, objectKind: String /* for logs */ ) +object WebhookEvent { + given Codec[WebhookEvent] = Codec.forProduct2("project", "object_kind")(apply)(we => (we.project, we.objectKind)) } -import CirceConfiguration._ - -@ConfiguredJsonCodec -final case class WebhookEvent(project: Project, objectKind: String /* for logs */ ) - -@ConfiguredJsonCodec final case class Project( id: Long -) +) derives Codec.AsObject object Project { val demo = Project(20190338) diff --git a/src/main/scala/io/pg/Application.scala b/src/main/scala/io/pg/Application.scala index ab0bc7e4..3cdb4d14 100644 --- a/src/main/scala/io/pg/Application.scala +++ b/src/main/scala/io/pg/Application.scala @@ -36,7 +36,7 @@ object Application { def resource[F[_]: Logger: Async]( config: AppConfig ): Resource[F, Application[F]] = { - implicit val projectConfigReader = ProjectConfigReader.test[F] + given ProjectConfigReader[F] = ProjectConfigReader.test[F] Queue .bounded[F, Event](config.queues.maxSize) @@ -44,7 +44,7 @@ object Application { .toResource .flatMap { eventChannel => implicit val webhookChannel: Channel[F, WebhookEvent] = - eventChannel.only[Event.Webhook].imap(_.value)(Event.Webhook) + eventChannel.only[Event.Webhook].imap(_.value)(Event.Webhook.apply) BlazeClientBuilder[F] .resource diff --git a/src/main/scala/io/pg/Main.scala b/src/main/scala/io/pg/Main.scala index 4c89cf25..7ba8559f 100644 --- a/src/main/scala/io/pg/Main.scala +++ b/src/main/scala/io/pg/Main.scala @@ -78,12 +78,12 @@ object Main extends IOApp { def serve[F[_]: Async](fToIO: F ~> IO)(config: AppConfig) = for { - implicit0(logger: Logger[F]) <- mkLogger[F](fToIO) - _ <- logStarting(config.meta).toResource - resources <- Application.resource[F](config) - _ <- mkServer[F](config, resources.routes) - _ <- resources.background.parTraverse_(_.run).background - _ <- logStarted(config.meta).toResource + given Logger[F] <- mkLogger[F](fToIO) + _ <- logStarting(config.meta).toResource + resources <- Application.resource[F](config) + _ <- mkServer[F](config, resources.routes) + _ <- resources.background.parTraverse_(_.run).background + _ <- logStarted(config.meta).toResource } yield () def run(args: List[String]): IO[ExitCode] = diff --git a/src/main/scala/io/pg/MergeRequests.scala b/src/main/scala/io/pg/MergeRequests.scala index 58d3250e..1f9faaf6 100644 --- a/src/main/scala/io/pg/MergeRequests.scala +++ b/src/main/scala/io/pg/MergeRequests.scala @@ -6,7 +6,6 @@ import cats.Show import cats.data.EitherNel import cats.data.NonEmptyList import cats.implicits._ -import cats.tagless.finalAlg import fs2.Pipe import io.odin.Logger import io.pg.MergeRequestState @@ -16,12 +15,13 @@ import io.pg.StateResolver import io.pg.config.ProjectConfigReader import io.pg.gitlab.webhook.Project -@finalAlg trait MergeRequests[F[_]] { def build(project: Project): F[List[MergeRequestState]] } object MergeRequests { + def apply[F[_]](using F: MergeRequests[F]): MergeRequests[F] = F + import scala.util.chaining._ def instance[F[_]: ProjectConfigReader: StateResolver: Monad: Logger]( diff --git a/src/main/scala/io/pg/actions.scala b/src/main/scala/io/pg/actions.scala index 42b93427..d780a7a6 100644 --- a/src/main/scala/io/pg/actions.scala +++ b/src/main/scala/io/pg/actions.scala @@ -176,7 +176,7 @@ object ProjectActions { matchers .traverse(_.matches(input).swap) .swap - .leftMap(Mismatch.ManyFailed) + .leftMap(Mismatch.ManyFailed.apply) .toEitherNel def not[A](matcher: MatcherFunction[A]): MatcherFunction[A] = input => @@ -211,9 +211,7 @@ object ProjectActions { } -sealed trait ProjectAction extends Product with Serializable - -object ProjectAction { - final case class Merge(projectId: Long, mergeRequestIid: Long) extends ProjectAction - final case class Rebase(projectId: Long, mergeRequestIid: Long) extends ProjectAction +enum ProjectAction { + case Merge(projectId: Long, mergeRequestIid: Long) + case Rebase(projectId: Long, mergeRequestIid: Long) } diff --git a/src/main/scala/io/pg/appconfig.scala b/src/main/scala/io/pg/appconfig.scala index b866bfca..63677d88 100644 --- a/src/main/scala/io/pg/appconfig.scala +++ b/src/main/scala/io/pg/appconfig.scala @@ -38,7 +38,7 @@ object AppConfig { default(bannerString), default(BuildInfo.version), default(BuildInfo.scalaVersion) - ).parMapN(MetaConfig) + ).parMapN(MetaConfig.apply) implicit val decodeUri: ConfigDecoder[String, Uri] = ConfigDecoder[String, String].mapEither { (key, value) => @@ -54,10 +54,10 @@ object AppConfig { env("GIT_API_TOKEN").secret ).mapN(Git.apply) - private val queuesConfig: ConfigValue[ciris.Effect, Queues] = default(100).map(Queues) + private val queuesConfig: ConfigValue[ciris.Effect, Queues] = default(100).map(Queues.apply) private val middlewareConfig: ConfigValue[ciris.Effect, MiddlewareConfig] = - default(Headers.SensitiveHeaders + CIString("Private-Token")).map(MiddlewareConfig) + default(Headers.SensitiveHeaders + CIString("Private-Token")).map(MiddlewareConfig.apply) val appConfig: ConfigValue[ciris.Effect, AppConfig] = (httpConfig, metaConfig, gitConfig, queuesConfig, middlewareConfig).parMapN(apply) diff --git a/src/main/scala/io/pg/config/ProjectConfig.scala b/src/main/scala/io/pg/config/ProjectConfig.scala index 2b28fdd3..8a6c87b5 100644 --- a/src/main/scala/io/pg/config/ProjectConfig.scala +++ b/src/main/scala/io/pg/config/ProjectConfig.scala @@ -4,19 +4,18 @@ import cats.Applicative import cats.MonadThrow import cats.effect.ExitCode import cats.syntax.all._ -import cats.tagless.finalAlg import io.github.vigoo.prox.ProxFS2 import io.pg.gitlab.webhook.Project import java.nio.file.Paths import scala.util.chaining._ -@finalAlg trait ProjectConfigReader[F[_]] { def readConfig(project: Project): F[ProjectConfig] } object ProjectConfigReader { + def apply[F[_]](using F: ProjectConfigReader[F]): ProjectConfigReader[F] = F def test[F[_]: Applicative]: ProjectConfigReader[F] = new ProjectConfigReader[F] { diff --git a/src/main/scala/io/pg/config/format.scala b/src/main/scala/io/pg/config/format.scala index e1812d7f..690d8d3f 100644 --- a/src/main/scala/io/pg/config/format.scala +++ b/src/main/scala/io/pg/config/format.scala @@ -1,9 +1,6 @@ package io.pg.config import cats.implicits._ -import io.circe.generic.extras.Configuration -import io.circe.generic.extras.ConfiguredJsonCodec -import io.circe.generic.extras.semiauto._ import scala.util.matching.Regex import io.circe.Codec import io.circe.Decoder @@ -11,8 +8,6 @@ import io.circe.Encoder import io.circe.DecodingFailure object circe { - implicit val circeConfig: Configuration = - Configuration.default.withDiscriminator("kind") private val decodeRegex: Decoder[Regex] = Decoder.instance { _.value @@ -28,49 +23,49 @@ object circe { implicit val regexCodec: Codec[Regex] = Codec.from(decodeRegex, encodeRegex) } -import circe.circeConfig import circe.regexCodec -sealed trait TextMatcher extends Product with Serializable +enum TextMatcher { + case Equals(value: String) + case Matches(regex: Regex) +} object TextMatcher { - final case class Equals(value: String) extends TextMatcher - final case class Matches(regex: Regex) extends TextMatcher - implicit val codec: Codec[TextMatcher] = deriveConfiguredCodec + given Codec[TextMatcher] = ??? // todo: discriminator: kind } -@ConfiguredJsonCodec -sealed trait Matcher extends Product with Serializable { +enum Matcher { def and(another: Matcher): Matcher = Matcher.Many(List(this, another)) + + case Author(email: TextMatcher) + case Description(text: TextMatcher) + case PipelineStatus(status: String) + case Many(values: List[Matcher]) + case OneOf(values: List[Matcher]) + case Not(underlying: Matcher) + } object Matcher { - final case class Author(email: TextMatcher) extends Matcher - final case class Description(text: TextMatcher) extends Matcher - final case class PipelineStatus(status: String) extends Matcher - final case class Many(values: List[Matcher]) extends Matcher - final case class OneOf(values: List[Matcher]) extends Matcher - final case class Not(underlying: Matcher) extends Matcher + given Codec[Matcher] = ??? // todo: discriminator: kind } //todo: remove this type altogether and assume Merge for now? -sealed trait Action extends Product with Serializable +enum Action { + case Merge +} object Action { - case object Merge extends Action - - implicit val codec: Codec[Action] = deriveEnumerationCodec + given Codec[Action] = ??? /* deriveEnumerationCodec */ } -@ConfiguredJsonCodec -final case class Rule(name: String, matcher: Matcher, action: Action) +final case class Rule(name: String, matcher: Matcher, action: Action) derives Codec.AsObject object Rule { val mergeAnything = Rule("anything", Matcher.Many(Nil), Action.Merge) } -@ConfiguredJsonCodec -final case class ProjectConfig(rules: List[Rule]) +final case class ProjectConfig(rules: List[Rule]) derives Codec.AsObject object ProjectConfig { val empty = ProjectConfig(Nil) diff --git a/src/main/scala/io/pg/resolver.scala b/src/main/scala/io/pg/resolver.scala index 60f7b0aa..c39ae189 100644 --- a/src/main/scala/io/pg/resolver.scala +++ b/src/main/scala/io/pg/resolver.scala @@ -3,23 +3,22 @@ package io.pg import cats.MonadError import cats.implicits._ import cats.kernel.Order -import cats.tagless.finalAlg import io.odin.Logger import io.pg.gitlab.Gitlab import io.pg.gitlab.Gitlab.MergeRequestInfo import io.pg.gitlab.webhook.Project -import io.scalaland.chimney.dsl._ import cats.Show -import monocle.macros.Lenses +import cats.MonadThrow +import monocle.syntax.all._ -@finalAlg trait StateResolver[F[_]] { def resolve(project: Project): F[List[MergeRequestState]] } object StateResolver { + def apply[F[_]](using F: StateResolver[F]): StateResolver[F] = F - def instance[F[_]: Gitlab: Logger: MonadError[*[_], Throwable]]( + def instance[F[_]: Gitlab: Logger: MonadThrow]( implicit SC: fs2.Compiler[F, F] ): StateResolver[F] = new StateResolver[F] { @@ -34,20 +33,19 @@ object StateResolver { private def buildState( mr: MergeRequestInfo ): MergeRequestState = - mr - .into[MergeRequestState] - .withFieldComputed(_.status, _.status.getOrElse(MergeRequestInfo.Status.Success)) // for now - no pipeline means success - .withFieldComputed( - _.mergeability, - info => - MergeRequestState - .Mergeability - .fromFlags( - hasConflicts = info.hasConflicts, - needsRebase = info.needsRebase - ) - ) - .transform + MergeRequestState( + projectId = mr.projectId, + mergeRequestIid = mr.mergeRequestIid, + authorUsername = mr.authorUsername, + description = mr.description, + status = mr.status.getOrElse(MergeRequestInfo.Status.Success), + mergeability = MergeRequestState + .Mergeability + .fromFlags( + hasConflicts = mr.hasConflicts, + needsRebase = mr.needsRebase + ) + ) def resolve(project: Project): F[List[MergeRequestState]] = findMergeRequests(project) @@ -61,7 +59,6 @@ object StateResolver { //current MR state - rebuilt on every event. //Checked against rules to come up with a decision. -@Lenses final case class MergeRequestState( projectId: Long, mergeRequestIid: Long, @@ -88,5 +85,5 @@ object MergeRequestState { } implicit val showTrimmed: Show[MergeRequestState] = - MergeRequestState.description.modify(_.map(TextUtils.trim(maxChars = 80))).apply(_).toString + _.focus(_.description).modify(_.map(TextUtils.trim(maxChars = 80))).toString } diff --git a/src/main/scala/io/pg/transport/transport.scala b/src/main/scala/io/pg/transport/transport.scala index 8ef25e59..e3cc068b 100644 --- a/src/main/scala/io/pg/transport/transport.scala +++ b/src/main/scala/io/pg/transport/transport.scala @@ -1,17 +1,7 @@ package io.pg.transport -import io.circe.generic.extras.Configuration -import io.circe.generic.extras.ConfiguredJsonCodec -import io.circe.generic.extras.semiauto.deriveEnumerationCodec import io.circe.Codec -private object CirceConfiguration { - implicit val circeConfig: Configuration = Configuration.default.withDiscriminator("@type") -} - -import CirceConfiguration._ - -@ConfiguredJsonCodec final case class MergeRequestState( projectId: Long, mergeRequestIid: Long, @@ -19,25 +9,17 @@ final case class MergeRequestState( description: Option[String], status: MergeRequestState.Status, mergeability: MergeRequestState.Mergeability -) +) derives Codec.AsObject object MergeRequestState { - @ConfiguredJsonCodec - sealed trait Status extends Product with Serializable - object Status { - case object Success extends Status - final case class Other(value: String) extends Status + enum Status derives Codec.AsObject { + case Success + case Other(value: String) } - sealed trait Mergeability extends Product with Serializable - - object Mergeability { - case object CanMerge extends Mergeability - case object NeedsRebase extends Mergeability - case object HasConflicts extends Mergeability - - implicit val codec: Codec[Mergeability] = deriveEnumerationCodec + enum Mergeability derives Codec.AsObject { + case CanMerge, NeedsRebase, HasConflicts } } diff --git a/src/main/scala/io/pg/webhook/webhook.scala b/src/main/scala/io/pg/webhook/webhook.scala index 5b8d613c..093849a8 100644 --- a/src/main/scala/io/pg/webhook/webhook.scala +++ b/src/main/scala/io/pg/webhook/webhook.scala @@ -10,11 +10,13 @@ import io.pg.gitlab.webhook.Project import io.pg.gitlab.webhook.WebhookEvent import io.pg.messaging.Publisher import io.pg.transport -import io.scalaland.chimney.dsl._ import org.http4s.HttpRoutes import org.http4s.circe.CirceEntityCodec._ import org.http4s.circe._ import org.http4s.dsl.Http4sDsl +import cats.MonadThrow +import io.pg.gitlab.Gitlab.MergeRequestInfo +import io.pg.MergeRequestState.Mergeability object WebhookRouter { @@ -31,7 +33,28 @@ object WebhookRouter { case GET -> Root / "preview" / LongVar(projectId) => val proj = Project(projectId) - MergeRequests[F].build(proj).nested.map(_.transformInto[transport.MergeRequestState]).value.flatMap(Ok(_)) + MergeRequests[F] + .build(proj) + .nested + .map { s => + transport.MergeRequestState( + projectId = s.projectId, + mergeRequestIid = s.mergeRequestIid, + description = s.description, + status = s.status match { + case MergeRequestInfo.Status.Success => transport.MergeRequestState.Status.Success + case MergeRequestInfo.Status.Other(s) => transport.MergeRequestState.Status.Other(s) + }, + mergeability = s.mergeability match { + case Mergeability.CanMerge => transport.MergeRequestState.Mergeability.CanMerge + case Mergeability.HasConflicts => transport.MergeRequestState.Mergeability.HasConflicts + case Mergeability.NeedsRebase => transport.MergeRequestState.Mergeability.NeedsRebase + }, + authorUsername = s.authorUsername + ) + } + .value + .flatMap(Ok(_)) } } @@ -43,9 +66,7 @@ object WebhookProcessor { def instance[ F[ _ - ]: MergeRequests: ProjectActions: Logger: MonadError[*[ - _ - ], Throwable] + ]: MergeRequests: ProjectActions: Logger: MonadThrow ]: WebhookEvent => F[Unit] = { ev => for { _ <- Logger[F].info("Received event", Map("event" -> ev.toString())) diff --git a/src/test/scala/io/pg/WebhookProcessorTest.scala b/src/test/scala/io/pg/WebhookProcessorTest.scala index caabab7e..ce265e04 100644 --- a/src/test/scala/io/pg/WebhookProcessorTest.scala +++ b/src/test/scala/io/pg/WebhookProcessorTest.scala @@ -17,6 +17,7 @@ import io.pg.config.Matcher import io.pg.config.Action import io.pg.config.TextMatcher import io.pg.MergeRequestState.Mergeability +import io.odin.Logger object WebhookProcessorTest extends SimpleIOSuite { @@ -33,7 +34,7 @@ object WebhookProcessorTest extends SimpleIOSuite { ProjectConfigReaderFake .refInstance[IO] .flatMap { implicit configReader => - implicit val logger = io.odin.consoleLogger[IO]() + given Logger[IO] = io.odin.consoleLogger[IO]() ProjectActionsStateFake.refInstance[IO].map { implicit projects => implicit val mergeRequests: MergeRequests[IO] = MergeRequests.instance[IO] @@ -110,10 +111,14 @@ object WebhookProcessorTest extends SimpleIOSuite { _ <- projectModifiers.finishPipeline(projectId, mr2) _ <- projectModifiers.setMergeability(projectId, mr2, Mergeability.NeedsRebase) - (mergeRequestsAfterProcess1, logAfterProcess1) <- perform - (mergeRequestsAfterProcess2, logAfterProcess2) <- perform - (mergeRequestsAfterProcess3, logAfterProcess3) <- perform + result1 <- perform + result2 <- perform + result3 <- perform } yield { + val (mergeRequestsAfterProcess1, logAfterProcess1) = result1 + val (mergeRequestsAfterProcess2, logAfterProcess2) = result2 + val (mergeRequestsAfterProcess3, logAfterProcess3) = result3 + val merge1 = ProjectAction.Merge(projectId, mr1) val rebase2 = ProjectAction.Rebase(projectId, mr2) val merge2 = ProjectAction.Merge(projectId, mr2) diff --git a/src/test/scala/io/pg/fakes/ProjectActionsStateFake.scala b/src/test/scala/io/pg/fakes/ProjectActionsStateFake.scala index fea01877..ea342f18 100644 --- a/src/test/scala/io/pg/fakes/ProjectActionsStateFake.scala +++ b/src/test/scala/io/pg/fakes/ProjectActionsStateFake.scala @@ -15,17 +15,16 @@ import io.pg.ProjectActions import io.pg.StateResolver import io.pg.gitlab.Gitlab.MergeRequestInfo import io.pg.gitlab.webhook.Project -import io.scalaland.chimney.dsl._ -import monocle.macros.Lenses +import monocle.syntax.all._ object ProjectActionsStateFake { sealed case class MergeRequestDescription(projectId: Long, mergeRequestIid: Long) object MergeRequestDescription { - val fromMergeAction: ProjectAction.Merge => MergeRequestDescription = _.transformInto[MergeRequestDescription] + val fromMergeAction: ProjectAction.Merge => MergeRequestDescription = merge => + MergeRequestDescription(merge.projectId, merge.mergeRequestIid) } - @Lenses sealed case class State( mergeRequests: Map[MergeRequestDescription, MergeRequestState], actionLog: Chain[ProjectAction] @@ -47,26 +46,26 @@ object ProjectActionsStateFake { private[ProjectActionsStateFake] object modifications { def logAction(action: ProjectAction): State => State = - State.actionLog.modify(_.append(action)) + _.focus(_.actionLog).modify(_.append(action)) def merge(action: ProjectAction.Merge): State => State = - State.mergeRequests.modify(_ - MergeRequestDescription.fromMergeAction(action)) + _.focus(_.mergeRequests).modify(_ - MergeRequestDescription.fromMergeAction(action)) def rebase(action: ProjectAction.Rebase): State => State = // Note: this doesn't check for conflicts setMergeabilityInternal(action.projectId, action.mergeRequestIid, Mergeability.CanMerge) def setMergeabilityInternal(projectId: Long, mergeRequestIid: Long, mergeability: Mergeability): State => State = - State.mergeRequests.modify { mrs => + _.focus(_.mergeRequests).modify { mrs => val key = MergeRequestDescription(projectId, mergeRequestIid) mrs ++ mrs.get(key).map(_.copy(mergeability = mergeability)).tupleLeft(key) } - def save(key: MergeRequestDescription, state: MergeRequestState) = State.mergeRequests.modify { + def save(key: MergeRequestDescription, state: MergeRequestState) = (_: State).focus(_.mergeRequests).modify { _ + (key -> state) } - def finishPipeline(key: MergeRequestDescription) = State.mergeRequests.modify { + def finishPipeline(key: MergeRequestDescription) = (_: State).focus(_.mergeRequests).modify { _.updatedWith(key) { _.map { state => state.copy(status = MergeRequestInfo.Status.Success) diff --git a/src/test/scala/io/pg/fakes/ProjectConfigReaderFake.scala b/src/test/scala/io/pg/fakes/ProjectConfigReaderFake.scala index 4597dd3b..863a22c2 100644 --- a/src/test/scala/io/pg/fakes/ProjectConfigReaderFake.scala +++ b/src/test/scala/io/pg/fakes/ProjectConfigReaderFake.scala @@ -7,13 +7,12 @@ import cats.mtl.Stateful import io.pg.config.ProjectConfig import io.pg.config.ProjectConfigReader import io.pg.gitlab.webhook.Project -import monocle.macros.Lenses +import monocle.syntax.all._ trait FakeState object ProjectConfigReaderFake { - @Lenses sealed case class State( configs: Map[Long, ProjectConfig] ) @@ -44,7 +43,7 @@ object ProjectConfigReaderFake { .flatMap(_.configs.get(project.id).liftTo[F](new Throwable(s"Unknown project: $project"))) def register(projectId: Long, config: ProjectConfig): F[Unit] = - Data[F].modify(State.configs.modify(_ + (projectId -> config))) + Data[F].modify(_.focus(_.configs).modify(_ + (projectId -> config))) } From 79d08848352c8781e5673b3885914835add6e1fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Koz=C5=82owski?= Date: Sat, 21 May 2022 18:31:44 +0200 Subject: [PATCH 2/7] Implement missing codecs --- src/main/scala/io/pg/config/format.scala | 63 ++++++++++++++++++++++-- 1 file changed, 60 insertions(+), 3 deletions(-) diff --git a/src/main/scala/io/pg/config/format.scala b/src/main/scala/io/pg/config/format.scala index 690d8d3f..6a2cdfc9 100644 --- a/src/main/scala/io/pg/config/format.scala +++ b/src/main/scala/io/pg/config/format.scala @@ -6,6 +6,7 @@ import io.circe.Codec import io.circe.Decoder import io.circe.Encoder import io.circe.DecodingFailure +import io.circe.Json object circe { @@ -21,9 +22,12 @@ object circe { private val encodeRegex: Encoder[Regex] = Encoder.encodeString.contramap[Regex](_.toString) implicit val regexCodec: Codec[Regex] = Codec.from(decodeRegex, encodeRegex) + + def writeWithKind[A: Encoder](a: A, kind: String) = Encoder[A].apply(a).mapObject(_.add("kind", Json.fromString(kind))) } import circe.regexCodec +import circe.writeWithKind enum TextMatcher { case Equals(value: String) @@ -31,7 +35,24 @@ enum TextMatcher { } object TextMatcher { - given Codec[TextMatcher] = ??? // todo: discriminator: kind + + given Codec[TextMatcher] = { + given Codec[Equals] = Codec.AsObject.derived + given Codec[Matches] = Codec.AsObject.derived + + val decoder: Decoder[TextMatcher] = Decoder[String].at("kind").flatMap { + case "Qquals" => Decoder[Equals].widen + case "Matches" => Decoder[Matches].widen + } + + val encoder: Encoder[TextMatcher] = { + case a: Equals => writeWithKind(a, "Equals") + case a: Matches => writeWithKind(a, "Matches") + } + + Codec.from(decoder, encoder) + } + } enum Matcher { @@ -47,7 +68,36 @@ enum Matcher { } object Matcher { - given Codec[Matcher] = ??? // todo: discriminator: kind + + given Codec[Matcher] = { + given Codec[Author] = Codec.AsObject.derived + given Codec[Description] = Codec.AsObject.derived + given Codec[PipelineStatus] = Codec.AsObject.derived + given Codec[Many] = Codec.AsObject.derived + given Codec[OneOf] = Codec.AsObject.derived + given Codec[Not] = Codec.AsObject.derived + + val decoder: Decoder[Matcher] = Decoder[String].at("kind").flatMap { + case "Author" => Decoder[Author].widen + case "Description" => Decoder[Description].widen + case "PipelineStatus" => Decoder[PipelineStatus].widen + case "Nany" => Decoder[Many].widen + case "OneOf" => Decoder[OneOf].widen + case "Not" => Decoder[Not].widen + } + + val encoder: Encoder[Matcher] = { + case a: Author => writeWithKind(a, "Author") + case a: Description => writeWithKind(a, "Description") + case a: PipelineStatus => writeWithKind(a, "PipelineStatus") + case a: Many => writeWithKind(a, "Many") + case a: OneOf => writeWithKind(a, "OneOf") + case a: Not => writeWithKind(a, "Not") + } + + Codec.from(decoder, encoder) + } + } //todo: remove this type altogether and assume Merge for now? @@ -56,7 +106,14 @@ enum Action { } object Action { - given Codec[Action] = ??? /* deriveEnumerationCodec */ + + given Codec[Action] = Codec + .from(Decoder[String], Encoder[String]) + .iemap { + case "Merge" => Action.Merge.asRight + case s => ("Unknown action: " + s).asLeft + }(_.toString) + } final case class Rule(name: String, matcher: Matcher, action: Action) derives Codec.AsObject From 59ed666633f7af43baff3cda9b1df31b9df8edde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Koz=C5=82owski?= Date: Sat, 21 May 2022 18:33:45 +0200 Subject: [PATCH 3/7] Remove commented out deps --- build.sbt | 7 ------- 1 file changed, 7 deletions(-) diff --git a/build.sbt b/build.sbt index dab07507..e5d821ca 100644 --- a/build.sbt +++ b/build.sbt @@ -69,10 +69,7 @@ def crossPlugin(x: sbt.librarymanagement.ModuleID) = compilerPlugin(x.cross(CrossVersion.full)) val compilerPlugins = List( - // crossPlugin("org.typelevel" % "kind-projector" % "0.13.2"), - // crossPlugin("com.github.cb372" % "scala-typed-holes" % "0.1.11"), crossPlugin("org.polyvariant" % "better-tostring" % "0.3.15") - // compilerPlugin("com.olegpy" %% "better-monadic-for" % "0.3.1") ) val commonSettings = List( @@ -80,7 +77,6 @@ val commonSettings = List( libraryDependencies ++= List( "org.typelevel" %% "cats-core" % "2.7.0", "org.typelevel" %% "cats-effect" % "3.3.12", - // "org.typelevel" %% "cats-tagless-macros" % "0.14.0", "co.fs2" %% "fs2-core" % "3.2.7", "com.github.valskalla" %% "odin-core" % "0.13.0", "io.circe" %% "circe-core" % "0.14.2", @@ -98,7 +94,6 @@ lazy val gitlab = project libraryDependencies ++= List( "is.cir" %% "ciris" % "2.3.2", "com.kubukoz" %% "caliban-gitlab" % "0.1.0", - // "io.circe" %% "circe-generic-extras" % "0.14.2", "io.circe" %% "circe-parser" % "0.14.2" % Test, "io.circe" %% "circe-literal" % "0.14.2" % Test, "com.softwaremill.sttp.tapir" %% "tapir-core" % "0.18.0-M17", @@ -121,7 +116,6 @@ lazy val bootstrap = project crossPlugin("org.polyvariant" % "better-tostring" % "0.3.15") ), publish / skip := true, - // Compile / mainClass := Some("org.polyvariant.Main"), githubWorkflowArtifactUpload := false, nativeImageVersion := "22.1.0", nativeImageOptions ++= Seq( @@ -196,7 +190,6 @@ lazy val pitgull = "org.http4s" %% "http4s-blaze-server" % "0.23.11", "org.http4s" %% "http4s-blaze-client" % "0.23.11", "is.cir" %% "ciris" % "2.3.2", - // "io.circe" %% "circe-generic-extras" % "0.14.0", "io.chrisdavenport" %% "cats-time" % "0.4.0", "com.github.valskalla" %% "odin-core" % "0.13.0", "com.github.valskalla" %% "odin-slf4j" % "0.13.0", From 955bb67f11ee71fc8939f0f3a18de59ec74a2d34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Koz=C5=82owski?= Date: Sat, 21 May 2022 19:11:02 +0200 Subject: [PATCH 4/7] Automated codec derivation --- .../io/pg/config/DiscriminatedCodec.scala | 55 +++++++++++++++++++ src/main/scala/io/pg/config/format.scala | 54 +----------------- 2 files changed, 57 insertions(+), 52 deletions(-) create mode 100644 src/main/scala/io/pg/config/DiscriminatedCodec.scala diff --git a/src/main/scala/io/pg/config/DiscriminatedCodec.scala b/src/main/scala/io/pg/config/DiscriminatedCodec.scala new file mode 100644 index 00000000..cb7a7e3f --- /dev/null +++ b/src/main/scala/io/pg/config/DiscriminatedCodec.scala @@ -0,0 +1,55 @@ +package io.pg.config + +import io.circe.Codec +import io.circe.Decoder +import io.circe.DecodingFailure +import io.circe.Json +import scala.deriving.Mirror + +// Temporary replacement for https://github.com/circe/circe/pull/1800 +object DiscriminatedCodec { + + import scala.deriving._ + import scala.compiletime._ + + private inline def deriveAll[T <: Tuple]: List[Codec.AsObject[_]] = inline erasedValue[T] match { + case _: EmptyTuple => Nil + case _: (h *: t) => + Codec + .AsObject + .derived[h]( + // feels odd but works + using summonInline[Mirror.Of[h]] + ) :: deriveAll[t] + } + + inline def derived[A](discriminator: String)(using inline m: Mirror.SumOf[A]): Codec.AsObject[A] = { + + val codecs: List[Codec.AsObject[A]] = deriveAll[m.MirroredElemTypes].map(_.asInstanceOf[Codec.AsObject[A]]) + + val labels = + summonAll[Tuple.Map[m.MirroredElemLabels, ValueOf]] + .toList + .asInstanceOf[List[ValueOf[String]]] + .map(_.value) + + Codec + .AsObject + .from[A]( + Decoder[String].at(discriminator).flatMap { key => + val index = labels.indexOf(key) + + if (index < 0) Decoder.failedWithMessage(s"Unknown discriminator field $discriminator: $key") + else codecs(index) + }, + value => { + val index = m.ordinal(value) + + codecs(index) + .mapJsonObject(_.add(discriminator, Json.fromString(labels(index)))) + .encodeObject(value) + } + ) + } + +} diff --git a/src/main/scala/io/pg/config/format.scala b/src/main/scala/io/pg/config/format.scala index 6a2cdfc9..389b4835 100644 --- a/src/main/scala/io/pg/config/format.scala +++ b/src/main/scala/io/pg/config/format.scala @@ -22,12 +22,9 @@ object circe { private val encodeRegex: Encoder[Regex] = Encoder.encodeString.contramap[Regex](_.toString) implicit val regexCodec: Codec[Regex] = Codec.from(decodeRegex, encodeRegex) - - def writeWithKind[A: Encoder](a: A, kind: String) = Encoder[A].apply(a).mapObject(_.add("kind", Json.fromString(kind))) } import circe.regexCodec -import circe.writeWithKind enum TextMatcher { case Equals(value: String) @@ -35,24 +32,7 @@ enum TextMatcher { } object TextMatcher { - - given Codec[TextMatcher] = { - given Codec[Equals] = Codec.AsObject.derived - given Codec[Matches] = Codec.AsObject.derived - - val decoder: Decoder[TextMatcher] = Decoder[String].at("kind").flatMap { - case "Qquals" => Decoder[Equals].widen - case "Matches" => Decoder[Matches].widen - } - - val encoder: Encoder[TextMatcher] = { - case a: Equals => writeWithKind(a, "Equals") - case a: Matches => writeWithKind(a, "Matches") - } - - Codec.from(decoder, encoder) - } - + given Codec[TextMatcher] = DiscriminatedCodec.derived("kind") } enum Matcher { @@ -64,40 +44,10 @@ enum Matcher { case Many(values: List[Matcher]) case OneOf(values: List[Matcher]) case Not(underlying: Matcher) - } object Matcher { - - given Codec[Matcher] = { - given Codec[Author] = Codec.AsObject.derived - given Codec[Description] = Codec.AsObject.derived - given Codec[PipelineStatus] = Codec.AsObject.derived - given Codec[Many] = Codec.AsObject.derived - given Codec[OneOf] = Codec.AsObject.derived - given Codec[Not] = Codec.AsObject.derived - - val decoder: Decoder[Matcher] = Decoder[String].at("kind").flatMap { - case "Author" => Decoder[Author].widen - case "Description" => Decoder[Description].widen - case "PipelineStatus" => Decoder[PipelineStatus].widen - case "Nany" => Decoder[Many].widen - case "OneOf" => Decoder[OneOf].widen - case "Not" => Decoder[Not].widen - } - - val encoder: Encoder[Matcher] = { - case a: Author => writeWithKind(a, "Author") - case a: Description => writeWithKind(a, "Description") - case a: PipelineStatus => writeWithKind(a, "PipelineStatus") - case a: Many => writeWithKind(a, "Many") - case a: OneOf => writeWithKind(a, "OneOf") - case a: Not => writeWithKind(a, "Not") - } - - Codec.from(decoder, encoder) - } - + given Codec[Matcher] = DiscriminatedCodec.derived("kind") } //todo: remove this type altogether and assume Merge for now? From f7e5ea81ec6ef6415f63d88101eb5eb84ff837c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Koz=C5=82owski?= Date: Sat, 21 May 2022 19:11:17 +0200 Subject: [PATCH 5/7] Update workflows --- .github/workflows/ci.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 65e99e18..34f14ea0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,7 +22,7 @@ jobs: strategy: matrix: os: [ubuntu-latest] - scala: [2.13.6] + scala: [3.1.1] java: [graalvm-ce-java11@20.1.0] runs-on: ${{ matrix.os }} steps: @@ -78,7 +78,7 @@ jobs: strategy: matrix: os: [ubuntu-latest] - scala: [2.13.6] + scala: [3.1.1] java: [graalvm-ce-java11@20.1.0] runs-on: ${{ matrix.os }} steps: @@ -104,12 +104,12 @@ jobs: ~/Library/Caches/Coursier/v1 key: ${{ runner.os }}-sbt-cache-v2-${{ hashFiles('**/*.sbt') }}-${{ hashFiles('project/build.properties') }} - - name: Download target directories (2.13.6) + - name: Download target directories (3.1.1) uses: actions/download-artifact@v2 with: - name: target-${{ matrix.os }}-2.13.6-${{ matrix.java }} + name: target-${{ matrix.os }}-3.1.1-${{ matrix.java }} - - name: Inflate target directories (2.13.6) + - name: Inflate target directories (3.1.1) run: | tar xf targets.tar rm targets.tar From 4aece70d72b24ec44b253839fc820eaebfc3f067 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Koz=C5=82owski?= Date: Sat, 21 May 2022 19:42:13 +0200 Subject: [PATCH 6/7] Add tests for project config codec --- build.sbt | 5 +- src/main/scala/io/pg/appconfig.scala | 2 +- src/main/scala/io/pg/config/format.scala | 21 ++++-- .../io/pg/ProjectConfigFormatTests.scala | 73 +++++++++++++++++++ 4 files changed, 91 insertions(+), 10 deletions(-) create mode 100644 src/test/scala/io/pg/ProjectConfigFormatTests.scala diff --git a/build.sbt b/build.sbt index e5d821ca..7b91faa0 100644 --- a/build.sbt +++ b/build.sbt @@ -105,7 +105,7 @@ lazy val gitlab = project lazy val bootstrap = project .settings( - scalaVersion := "3.1.1", + scalaVersion := Scala3, libraryDependencies ++= List( "org.typelevel" %% "cats-core" % "2.7.0", "org.typelevel" %% "cats-effect" % "3.3.12", @@ -193,7 +193,8 @@ lazy val pitgull = "io.chrisdavenport" %% "cats-time" % "0.4.0", "com.github.valskalla" %% "odin-core" % "0.13.0", "com.github.valskalla" %% "odin-slf4j" % "0.13.0", - "io.github.vigoo" %% "prox-fs2-3" % "0.7.7" + "io.github.vigoo" %% "prox-fs2-3" % "0.7.7", + "io.circe" %% "circe-literal" % "0.14.2" % Test ) ) .dependsOn(core, gitlab) diff --git a/src/main/scala/io/pg/appconfig.scala b/src/main/scala/io/pg/appconfig.scala index 63677d88..2272878c 100644 --- a/src/main/scala/io/pg/appconfig.scala +++ b/src/main/scala/io/pg/appconfig.scala @@ -52,7 +52,7 @@ object AppConfig { default(Git.Host.Gitlab), env("GIT_API_URL").as[Uri], env("GIT_API_TOKEN").secret - ).mapN(Git.apply) + ).parMapN(Git.apply) private val queuesConfig: ConfigValue[ciris.Effect, Queues] = default(100).map(Queues.apply) diff --git a/src/main/scala/io/pg/config/format.scala b/src/main/scala/io/pg/config/format.scala index 389b4835..fb859f91 100644 --- a/src/main/scala/io/pg/config/format.scala +++ b/src/main/scala/io/pg/config/format.scala @@ -10,13 +10,11 @@ import io.circe.Json object circe { - private val decodeRegex: Decoder[Regex] = Decoder.instance { - _.value - .asString - .toRight(DecodingFailure("Failed to decode as String", Nil)) - .flatMap { s => - Either.catchNonFatal(s.r).leftMap(DecodingFailure.fromThrowable(_, Nil)) - } + private val decodeRegex: Decoder[Regex] = Decoder[String].flatMap { s => + Either + .catchNonFatal(s.r) + .leftMap(DecodingFailure.fromThrowable(_, Nil)) + .liftTo[Decoder] } private val encodeRegex: Encoder[Regex] = Encoder.encodeString.contramap[Regex](_.toString) @@ -29,6 +27,15 @@ import circe.regexCodec enum TextMatcher { case Equals(value: String) case Matches(regex: Regex) + + override def equals(another: Any) = (this, another) match { + // Regex uses reference equality by default. + // By using `.regex` we convert it back to a pattern string for better comparison. + case (Matches(p1), Matches(p2)) => p1.regex == p2.regex + case (Equals(e1), Equals(e2)) => e1 == e2 + case _ => false + } + } object TextMatcher { diff --git a/src/test/scala/io/pg/ProjectConfigFormatTests.scala b/src/test/scala/io/pg/ProjectConfigFormatTests.scala new file mode 100644 index 00000000..76dba7b2 --- /dev/null +++ b/src/test/scala/io/pg/ProjectConfigFormatTests.scala @@ -0,0 +1,73 @@ +package io.pg + +import weaver._ +import io.circe.literal._ +import io.pg.config.ProjectConfig +import io.pg.config.ProjectConfigReader +import io.pg.gitlab.webhook.Project +import io.pg.config.Rule +import io.pg.config.Action +import io.pg.config.Matcher +import io.pg.config.TextMatcher +import io.circe.syntax._ + +object ProjectConfigFormatTest extends FunSuite { + + val asJSON = json"""{ +"rules": [ + { + "action": "Merge", + "matcher": { + "kind": "Many", + "values": [ + { + "email": { + "kind": "Equals", + "value": "scala.steward@ocado.com" + }, + "kind": "Author" + }, + { + "kind": "Description", + "text": { + "kind": "Matches", + "regex": ".*labels:.*semver-patch.*" + } + }, + { + "kind": "PipelineStatus", + "status": "success" + } + ] + }, + "name": "Scala Steward" + } +] +} +""" + + val decoded = ProjectConfig( + rules = List( + Rule( + name = "Scala Steward", + action = Action.Merge, + matcher = Matcher.Many( + List( + Matcher.Author(TextMatcher.Equals("scala.steward@ocado.com")), + Matcher.Description(TextMatcher.Matches(".*labels:.*semver-patch.*".r)), + Matcher.PipelineStatus("success") + ) + ) + ) + ) + ) + + test("Example config can be decoded") { + val actual = asJSON.as[ProjectConfig] + assert(actual == Right(decoded)) + } + test("Example config can be encoded") { + val actual = decoded.asJson + assert.eql(actual, asJSON) + } +} From 20fe87f28c2563fe880d8b41edbb9f5bb7b6c9a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jakub=20Koz=C5=82owski?= Date: Sat, 21 May 2022 20:41:06 +0200 Subject: [PATCH 7/7] Add todos, remove self type, extract transformer --- .../scala/io/pg/messaging/messaging.scala | 9 +++-- .../src/main/scala/io/pg/gitlab/Gitlab.scala | 2 ++ .../scala/io/pg/gitlab/webhook/webhook.scala | 1 + src/main/scala/io/pg/webhook/webhook.scala | 35 ++++++++++--------- 4 files changed, 27 insertions(+), 20 deletions(-) diff --git a/core/src/main/scala/io/pg/messaging/messaging.scala b/core/src/main/scala/io/pg/messaging/messaging.scala index 0eafaef5..169fa60f 100644 --- a/core/src/main/scala/io/pg/messaging/messaging.scala +++ b/core/src/main/scala/io/pg/messaging/messaging.scala @@ -36,16 +36,19 @@ object Processor { } -trait Channel[F[_], A] extends Publisher[F, A] { self => +trait Channel[F[_], A] extends Publisher[F, A] { def consume: fs2.Stream[F, A] } object Channel { - given[F[_]]: Invariant[Channel[F, *]] with { + + given [F[_]]: Invariant[Channel[F, *]] with { + def imap[A, B](chan: Channel[F, A])(f: A => B)(g: B => A): Channel[F, B] = new { - def consume: fs2.Stream[F,B] = chan.consume.map(f) + def consume: fs2.Stream[F, B] = chan.consume.map(f) def publish(b: B): F[Unit] = chan.publish(g(b)) } + } def fromQueue[F[_]: Functor, A](q: Queue[F, A]): Channel[F, A] = diff --git a/gitlab/src/main/scala/io/pg/gitlab/Gitlab.scala b/gitlab/src/main/scala/io/pg/gitlab/Gitlab.scala index 1bafe5bc..beaaed68 100644 --- a/gitlab/src/main/scala/io/pg/gitlab/Gitlab.scala +++ b/gitlab/src/main/scala/io/pg/gitlab/Gitlab.scala @@ -281,12 +281,14 @@ object GitlabEndpoints { } object ApprovalRule { + // todo: use configured codec when https://github.com/circe/circe/pull/1800 is available given CirceCodec[ApprovalRule] = CirceCodec.forProduct3("id", "name", "rule_type")(apply)(r => (r.id, r.name, r.ruleType)) } final case class MergeRequestApprovals(approvalsRequired: Int) object MergeRequestApprovals { + // todo: use configured codec when https://github.com/circe/circe/pull/1800 is available given CirceCodec[MergeRequestApprovals] = CirceCodec.forProduct1("approvals_required")(apply)(_.approvalsRequired) } diff --git a/gitlab/src/main/scala/io/pg/gitlab/webhook/webhook.scala b/gitlab/src/main/scala/io/pg/gitlab/webhook/webhook.scala index 63cbe4ed..e8a638e6 100644 --- a/gitlab/src/main/scala/io/pg/gitlab/webhook/webhook.scala +++ b/gitlab/src/main/scala/io/pg/gitlab/webhook/webhook.scala @@ -5,6 +5,7 @@ import io.circe.Codec final case class WebhookEvent(project: Project, objectKind: String /* for logs */ ) object WebhookEvent { + // todo: use configured codec when https://github.com/circe/circe/pull/1800 is available given Codec[WebhookEvent] = Codec.forProduct2("project", "object_kind")(apply)(we => (we.project, we.objectKind)) } diff --git a/src/main/scala/io/pg/webhook/webhook.scala b/src/main/scala/io/pg/webhook/webhook.scala index 093849a8..70171f0a 100644 --- a/src/main/scala/io/pg/webhook/webhook.scala +++ b/src/main/scala/io/pg/webhook/webhook.scala @@ -17,6 +17,7 @@ import org.http4s.dsl.Http4sDsl import cats.MonadThrow import io.pg.gitlab.Gitlab.MergeRequestInfo import io.pg.MergeRequestState.Mergeability +import io.pg.MergeRequestState object WebhookRouter { @@ -36,29 +37,29 @@ object WebhookRouter { MergeRequests[F] .build(proj) .nested - .map { s => - transport.MergeRequestState( - projectId = s.projectId, - mergeRequestIid = s.mergeRequestIid, - description = s.description, - status = s.status match { - case MergeRequestInfo.Status.Success => transport.MergeRequestState.Status.Success - case MergeRequestInfo.Status.Other(s) => transport.MergeRequestState.Status.Other(s) - }, - mergeability = s.mergeability match { - case Mergeability.CanMerge => transport.MergeRequestState.Mergeability.CanMerge - case Mergeability.HasConflicts => transport.MergeRequestState.Mergeability.HasConflicts - case Mergeability.NeedsRebase => transport.MergeRequestState.Mergeability.NeedsRebase - }, - authorUsername = s.authorUsername - ) - } + .map(mergeRequestToTransport) .value .flatMap(Ok(_)) } } + private def mergeRequestToTransport(mr: MergeRequestState): io.pg.transport.MergeRequestState = transport.MergeRequestState( + projectId = mr.projectId, + mergeRequestIid = mr.mergeRequestIid, + description = mr.description, + status = mr.status match { + case MergeRequestInfo.Status.Success => transport.MergeRequestState.Status.Success + case MergeRequestInfo.Status.Other(s) => transport.MergeRequestState.Status.Other(s) + }, + mergeability = mr.mergeability match { + case Mergeability.CanMerge => transport.MergeRequestState.Mergeability.CanMerge + case Mergeability.HasConflicts => transport.MergeRequestState.Mergeability.HasConflicts + case Mergeability.NeedsRebase => transport.MergeRequestState.Mergeability.NeedsRebase + }, + authorUsername = mr.authorUsername + ) + } object WebhookProcessor {