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 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..7b91faa0 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,18 @@ 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.polyvariant" % "better-tostring" % "0.3.15") ) 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", "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 +94,8 @@ 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-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,18 +105,17 @@ lazy val gitlab = project lazy val bootstrap = project .settings( - scalaVersion := "3.0.0", + scalaVersion := Scala3, 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, - // Compile / mainClass := Some("org.polyvariant.Main"), githubWorkflowArtifactUpload := false, nativeImageVersion := "22.1.0", nativeImageOptions ++= Seq( @@ -195,12 +190,11 @@ 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.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/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..169fa60f 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,13 +36,21 @@ object Processor { } -@autoInvariant -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 { + + 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] { def publish(a: A): F[Unit] = q.offer(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..beaaed68 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,21 @@ 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 + // 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 { - implicit val codec: CirceCodec[MergeRequestApprovals] = deriveConfiguredCodec + // 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 e7d8c4c4..e8a638e6 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,17 @@ 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 { + // 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)) } -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..2272878c 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) => @@ -52,12 +52,12 @@ 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) + 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/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/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..fb859f91 100644 --- a/src/main/scala/io/pg/config/format.scala +++ b/src/main/scala/io/pg/config/format.scala @@ -1,26 +1,20 @@ 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 import io.circe.Encoder import io.circe.DecodingFailure +import io.circe.Json object circe { - implicit val circeConfig: Configuration = - Configuration.default.withDiscriminator("kind") - - 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) @@ -28,49 +22,64 @@ 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) + + 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 { - 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] = DiscriminatedCodec.derived("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] = DiscriminatedCodec.derived("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] = Codec + .from(Decoder[String], Encoder[String]) + .iemap { + case "Merge" => Action.Merge.asRight + case s => ("Unknown action: " + s).asLeft + }(_.toString) + } -@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..70171f0a 100644 --- a/src/main/scala/io/pg/webhook/webhook.scala +++ b/src/main/scala/io/pg/webhook/webhook.scala @@ -10,11 +10,14 @@ 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 +import io.pg.MergeRequestState object WebhookRouter { @@ -31,11 +34,32 @@ 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(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 { @@ -43,9 +67,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/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) + } +} 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))) }