From eb7e7a8badd57ca6749c7ce6e031038d1d0ed696 Mon Sep 17 00:00:00 2001 From: Thijs Broersen Date: Fri, 18 Oct 2024 22:46:30 +0200 Subject: [PATCH] add githubactionsnative model, restore old models --- .github/workflows/ci.yml | 1 + .../main/scala/zio/sbt/ZioSbtCiPlugin.scala | 42 +-- .../zio/sbt/githubactions/ScalaWorkflow.scala | 80 ++-- .../scala/zio/sbt/githubactions/model.scala | 346 +++++++++--------- .../scala/zio/sbt/githubactions/package.scala | 5 - .../githubactionsnative/ScalaWorkflow.scala | 266 ++++++++++++++ .../zio/sbt/githubactionsnative/model.scala | 344 +++++++++++++++++ .../zio/sbt/githubactionsnative/package.scala | 60 +++ 8 files changed, 901 insertions(+), 243 deletions(-) delete mode 100644 zio-sbt-githubactions/src/main/scala/zio/sbt/githubactions/package.scala create mode 100644 zio-sbt-githubactions/src/main/scala/zio/sbt/githubactionsnative/ScalaWorkflow.scala create mode 100644 zio-sbt-githubactions/src/main/scala/zio/sbt/githubactionsnative/model.scala create mode 100644 zio-sbt-githubactions/src/main/scala/zio/sbt/githubactionsnative/package.scala diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3c69b756..24d0b877 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,7 @@ name: CI env: JDK_JAVA_OPTIONS: -XX:+PrintCommandLineFlags 'on': + workflow_dispatch: {} release: types: - published diff --git a/zio-sbt-ci/src/main/scala/zio/sbt/ZioSbtCiPlugin.scala b/zio-sbt-ci/src/main/scala/zio/sbt/ZioSbtCiPlugin.scala index b6d29217..5c30471d 100644 --- a/zio-sbt-ci/src/main/scala/zio/sbt/ZioSbtCiPlugin.scala +++ b/zio-sbt-ci/src/main/scala/zio/sbt/ZioSbtCiPlugin.scala @@ -23,8 +23,8 @@ import sbt.{Def, io => _, _} import zio.json._ import zio.json.yaml._ -import zio.sbt.githubactions.Step.SingleStep -import zio.sbt.githubactions.{Job, Step, _} +import zio.sbt.githubactionsnative.Step.SingleStep +import zio.sbt.githubactionsnative.{Job, Step, _} object ZioSbtCiPlugin extends AutoPlugin { override def requires = plugins.CorePlugin @@ -368,8 +368,8 @@ object ZioSbtCiPlugin extends AutoPlugin { uses = Some(ActionRef(V("zio/generate-github-app-token"))), `with` = Some( Map( - "app_id" -> "${{ secrets.APP_ID }}", - "app_private_key" -> "${{ secrets.APP_PRIVATE_KEY }}" + "app_id" -> "${{ secrets.APP_ID }}".toJsonAST.right.get, + "app_private_key" -> "${{ secrets.APP_PRIVATE_KEY }}".toJsonAST.right.get ) ) ), @@ -379,17 +379,17 @@ object ZioSbtCiPlugin extends AutoPlugin { uses = Some(ActionRef(V("peter-evans/create-pull-request"))), `with` = Some( Map( - "title" -> "Update README.md", - "commit-message" -> "Update README.md", - "branch" -> "zio-sbt-website/update-readme", - "delete-branch" -> "true", + "title" -> "Update README.md".toJsonAST.right.get, + "commit-message" -> "Update README.md".toJsonAST.right.get, + "branch" -> "zio-sbt-website/update-readme".toJsonAST.right.get, + "delete-branch" -> "true".toJsonAST.right.get, "body" -> """|Autogenerated changes after running the `sbt docs/generateReadme` command of the [zio-sbt-website](https://zio.dev/zio-sbt) plugin. | |I will automatically update the README.md file whenever there is new change for README.md, e.g. | - After each release, I will update the version in the installation section. - | - After any changes to the "docs/index.md" file, I will update the README.md file accordingly.""".stripMargin, - "token" -> "${{ steps.generate-token.outputs.token }}" + | - After any changes to the "docs/index.md" file, I will update the README.md file accordingly.""".stripMargin.toJsonAST.right.get, + "token" -> "${{ steps.generate-token.outputs.token }}".toJsonAST.right.get ) ) ), @@ -534,12 +534,12 @@ object ZioSbtCiPlugin extends AutoPlugin { val workflow = Workflow( name = workflowName, - env = jvmMap ++ nodeMap, + env = Some(jvmMap ++ nodeMap), on = Some( Triggers( - release = Some(Triggers.Release(Seq(Triggers.ReleaseType.Published))), - push = Some(Triggers.Push(branches = Some(enabledBranches.map(Branch.Named)).filter(_.nonEmpty))), - pullRequest = Some(Triggers.PullRequest(branchesIgnore = Some(Seq(Branch.Named("gh-pages"))))) + release = Some(Trigger.Release(Seq(Trigger.ReleaseType.Published))), + push = Some(Trigger.Push(branches = Some(enabledBranches.map(Branch.Named)).filter(_.nonEmpty))), + pullRequest = Some(Trigger.PullRequest(branchesIgnore = Some(Seq(Branch.Named("gh-pages"))))) ) ), jobs = ListMap.empty[ @@ -645,7 +645,7 @@ object ZioSbtCiPlugin extends AutoPlugin { Step.SingleStep( name = "Set Swap Space", uses = Some(ActionRef(V("pierotofy/set-swap-space"))), - `with` = Some(Map("swap-size-gb" -> swapSizeGB.toString)) + `with` = Some(Map("swap-size-gb" -> swapSizeGB.toString.toJsonAST.right.get)) ) } @@ -654,7 +654,7 @@ object ZioSbtCiPlugin extends AutoPlugin { Step.SingleStep( name = "Git Checkout", uses = Some(ActionRef(V("actions/checkout"))), - `with` = Some(Map("fetch-depth" -> "0")) + `with` = Some(Map("fetch-depth" -> "0".toJsonAST.right.get)) ) } @@ -668,9 +668,9 @@ object ZioSbtCiPlugin extends AutoPlugin { uses = Some(ActionRef(V("actions/setup-java"))), `with` = Some( Map( - "distribution" -> "corretto", - "java-version" -> version, - "check-latest" -> "true" + "distribution" -> "corretto".toJsonAST.right.get, + "java-version" -> version.toJsonAST.right.get, + "check-latest" -> "true".toJsonAST.right.get ) ) ) @@ -727,8 +727,8 @@ object ZioSbtCiPlugin extends AutoPlugin { uses = Some(ActionRef(V("actions/setup-node"))), `with` = Some( Map( - "node-version" -> "16.x", - "registry-url" -> "https://registry.npmjs.org" + "node-version" -> "16.x".toJsonAST.right.get, + "registry-url" -> "https://registry.npmjs.org".toJsonAST.right.get ) ) ) diff --git a/zio-sbt-githubactions/src/main/scala/zio/sbt/githubactions/ScalaWorkflow.scala b/zio-sbt-githubactions/src/main/scala/zio/sbt/githubactions/ScalaWorkflow.scala index f895e3cd..3a57a047 100644 --- a/zio-sbt-githubactions/src/main/scala/zio/sbt/githubactions/ScalaWorkflow.scala +++ b/zio-sbt-githubactions/src/main/scala/zio/sbt/githubactions/ScalaWorkflow.scala @@ -17,10 +17,12 @@ package zio.sbt.githubactions import zio.json._ + import zio.sbt.githubactions.ScalaWorkflow.JavaVersion.JDK11 // The original code of the githubactions package was originally copied from the zio-aws-codegen project: // https://github.com/zio/zio-aws/tree/master/zio-aws-codegen/src/main/scala/zio/aws/codegen/githubactions +@deprecated("Use zio.sbt.githubactionsnative.ScalaWorkflow instead", "0.4.x") object ScalaWorkflow { import Step._ @@ -28,10 +30,8 @@ object ScalaWorkflow { SingleStep( name = "Checkout current branch", uses = Some(ActionRef("actions/checkout@v2")), - `with` = Some( - Map( - "fetch-depth" -> fetchDepth.toJson - ) + parameters = Map( + "fetch-depth" -> fetchDepth.toJsonAST.right.get ) ) @@ -39,13 +39,11 @@ object ScalaWorkflow { SingleStep( name = "Setup Java and Scala", uses = Some(ActionRef("olafurpg/setup-scala@v11")), - `with` = Some( - Map( - "java-version" -> (javaVersion match { - case None => "${{ matrix.java }}" - case Some(version) => version.asString - }) - ) + parameters = Map( + "java-version" -> (javaVersion match { + case None => "${{ matrix.java }}" + case Some(version) => version.asString + }).toJsonAST.right.get ) ) @@ -53,14 +51,12 @@ object ScalaWorkflow { SingleStep( name = "Setup NodeJS", uses = Some(ActionRef("actions/setup-node@v3")), - `with` = Some( - Map( - "node-version" -> (javaVersion match { - case None => "16.x" - case Some(version) => version.asString - }), - "registry-url" -> "https://registry.npmjs.org" - ) + parameters = Map( + "node-version" -> (javaVersion match { + case None => "16.x" + case Some(version) => version.asString + }).toJsonAST.right.get, + "registry-url" -> "https://registry.npmjs.org".toJsonAST.right.get ) ) @@ -80,16 +76,14 @@ object ScalaWorkflow { SingleStep( name = "Cache SBT", uses = Some(ActionRef("actions/cache@v2")), - `with` = Some( - Map( - "path" -> Seq( - "~/.ivy2/cache", - "~/.sbt", - "~/.coursier/cache/v1", - "~/.cache/coursier/v1" - ).mkString("\n"), - "key" -> s"$osS-sbt-$scalaS-$${{ hashFiles('**/*.sbt') }}-$${{ hashFiles('**/build.properties') }}" - ) + parameters = Map( + "path" -> Seq( + "~/.ivy2/cache", + "~/.sbt", + "~/.coursier/cache/v1", + "~/.cache/coursier/v1" + ).mkString("\n").toJsonAST.right.get, + "key" -> s"$osS-sbt-$scalaS-$${{ hashFiles('**/*.sbt') }}-$${{ hashFiles('**/build.properties') }}".toJsonAST.right.get ) ) } @@ -112,7 +106,7 @@ object ScalaWorkflow { run = Some( s"sbt -J-XX:+UseG1GC -J-Xmx${heapGb}g -J-Xms${heapGb}g -J-Xss${stackMb}m ${parameters.mkString(" ")}" ), - env = Some(env).filter(_.nonEmpty) + env = env ) def storeTargets( @@ -137,11 +131,9 @@ object ScalaWorkflow { SingleStep( s"Upload $id targets", uses = Some(ActionRef("actions/upload-artifact@v2")), - `with` = Some( - Map( - "name" -> s"target-$id-$osS-$scalaS-$javaS", - "path" -> "targets.tar" - ) + parameters = Map( + "name" -> s"target-$id-$osS-$scalaS-$javaS".toJsonAST.right.get, + "path" -> "targets.tar".toJsonAST.right.get ) ) ) @@ -163,10 +155,8 @@ object ScalaWorkflow { SingleStep( s"Download stored $id targets", uses = Some(ActionRef("actions/download-artifact@v2")), - `with` = Some( - Map( - "name" -> s"target-$id-$osS-$scalaS-$javaS" - ) + parameters = Map( + "name" -> s"target-$id-$osS-$scalaS-$javaS".toJsonAST.right.get ) ), SingleStep( @@ -193,17 +183,15 @@ object ScalaWorkflow { SingleStep( "Load PGP secret", run = Some(".github/import-key.sh"), - env = Some(Map("PGP_SECRET" -> "${{ secrets.PGP_SECRET }}")) + env = Map("PGP_SECRET" -> "${{ secrets.PGP_SECRET }}") ) def turnstyle(): Step = SingleStep( "Turnstyle", uses = Some(ActionRef("softprops/turnstyle@v1")), - env = Some( - Map( - "GITHUB_TOKEN" -> "${{ secrets.ADMIN_GITHUB_TOKEN }}" - ) + env = Map( + "GITHUB_TOKEN" -> "${{ secrets.ADMIN_GITHUB_TOKEN }}" ) ) @@ -244,12 +232,12 @@ object ScalaWorkflow { val JDK21: JavaVersion = CorrettoJDK("21") } - implicit class JobOps(job: JobValue) { + implicit class JobOps(job: Job) { def matrix( scalaVersions: Seq[ScalaVersion], operatingSystems: Seq[OS] = Seq(OS.UbuntuLatest), javaVersions: Seq[JavaVersion] = Seq(JDK11) - ): JobValue = + ): Job = job.copy( strategy = Some( Strategy( diff --git a/zio-sbt-githubactions/src/main/scala/zio/sbt/githubactions/model.scala b/zio-sbt-githubactions/src/main/scala/zio/sbt/githubactions/model.scala index 96d364be..74750a1d 100644 --- a/zio-sbt-githubactions/src/main/scala/zio/sbt/githubactions/model.scala +++ b/zio-sbt-githubactions/src/main/scala/zio/sbt/githubactions/model.scala @@ -16,16 +16,22 @@ package zio.sbt.githubactions -import scala.collection.immutable.ListMap -import scala.util.{Failure, Success, Try} +import zio.sbt.{githubactionsnative => ghnative} -import zio.json._ +import zio.json.ast.Json +import scala.collection.immutable.ListMap sealed trait OS { val asString: String } object OS { case object UbuntuLatest extends OS { val asString = "ubuntu-latest" } + + implicit class OSOps(val os: OS) extends AnyVal { + def toNative: ghnative.OS = os match { + case UbuntuLatest => ghnative.OS.UbuntuLatest + } + } } sealed trait Branch @@ -33,119 +39,97 @@ object Branch { case object All extends Branch case class Named(name: String) extends Branch - implicit val codec: JsonCodec[Branch] = JsonCodec.string.transform( - { - case "*" => All - case name => Named(name) - }, - { - case All => "*" - case Named(name) => name + implicit class BranchOps(val branch: Branch) extends AnyVal { + def toNative: ghnative.Branch = branch match { + case All => ghnative.Branch.All + case Named(name) => ghnative.Branch.Named(name) } - ) + } } -@jsonMemberNames(SnakeCase) -case class Triggers( - workflowDispatch: Option[Triggers.WorkflowDispatch] = None, - release: Option[Triggers.Release] = None, - pullRequest: Option[Triggers.PullRequest] = None, - push: Option[Triggers.Push] = None, - create: Option[Triggers.Create] = None -) +sealed trait Trigger -object Triggers { - case class InputValue(description: String, required: Boolean, default: String) - object InputValue { - implicit val jsonCodec: JsonCodec[InputValue] = DeriveJsonCodec.gen[InputValue] +case class Input(key: String, description: String, required: Boolean, defaultValue: String) + +object Input { + implicit class InputOps(val input: Input) extends AnyVal { + def toNative: (String, ghnative.Trigger.InputValue) = + input.key -> ghnative.Trigger.InputValue(input.description, input.required, input.defaultValue) } +} +object Trigger { case class WorkflowDispatch( - inputs: ListMap[String, InputValue] = ListMap.empty - ) - - object WorkflowDispatch { - implicit def listMapCodec[K: JsonFieldDecoder: JsonFieldEncoder, V: JsonCodec]: JsonCodec[ListMap[K, V]] = - JsonCodec( - JsonEncoder.keyValueIterable[K, V, ListMap], - JsonDecoder.keyValueChunk[K, V].map(c => ListMap(c: _*)) - ) - - implicit val jsonCodec: JsonCodec[WorkflowDispatch] = DeriveJsonCodec.gen[WorkflowDispatch] - } + inputs: Seq[Input] = Seq.empty + ) extends Trigger case class Release( - types: Seq[ReleaseType] = Seq.empty - ) - - object Release { - implicit val jsonCodec: JsonCodec[Release] = DeriveJsonCodec.gen[Release] - } + releaseTypes: Seq[String] = Seq.empty + ) extends Trigger - sealed trait ReleaseType - object ReleaseType { - case object Created extends ReleaseType - case object Published extends ReleaseType - case object Prereleased extends ReleaseType - - implicit val codec: JsonCodec[ReleaseType] = JsonCodec.string.transformOrFail( - { - case "created" => Right(Created) - case "published" => Right(Published) - case "prereleased" => Right(Prereleased) - case other => Left(s"Invalid release type: $other") - }, - { - case Created => "created" - case Published => "published" - case Prereleased => "prereleased" - } - ) - } - - @jsonMemberNames(KebabCase) case class PullRequest( - // types: Option[Seq[PullRequestType]] = None, - branches: Option[Seq[Branch]] = None, - branchesIgnore: Option[Seq[Branch]] = None, - paths: Option[Seq[String]] = None - ) - - object PullRequest { - implicit val jsonCodec: JsonCodec[PullRequest] = DeriveJsonCodec.gen[PullRequest] - } + branches: Seq[Branch] = Seq.empty, + ignoredBranches: Seq[Branch] = Seq.empty + ) extends Trigger case class Push( - branches: Option[Seq[Branch]] = None, - branchesIgnore: Option[Seq[Branch]] = None - ) - - object Push { - implicit val jsonCodec: JsonCodec[Push] = DeriveJsonCodec.gen[Push] - } + branches: Seq[Branch] = Seq.empty, + ignoredBranches: Seq[Branch] = Seq.empty + ) extends Trigger case class Create( - branches: Option[Seq[Branch]] = None, - branchesIgnore: Option[Seq[Branch]] = None - ) - - object Create { - implicit val jsonCodec: JsonCodec[Create] = DeriveJsonCodec.gen[Create] + branches: Seq[Branch] = Seq.empty, + ignoredBranches: Seq[Branch] = Seq.empty + ) extends Trigger + + implicit class TriggerOps(val trigger: Trigger) extends AnyVal { + def toNative: ghnative.Trigger = trigger match { + case WorkflowDispatch(inputs) => + ghnative.Trigger.WorkflowDispatch(Some(ListMap(inputs.map(_.toNative): _*)).filter(_.nonEmpty)) + case Release(releaseTypes) => + ghnative.Trigger.Release(releaseTypes.map { + case "created" => ghnative.Trigger.ReleaseType.Created + case "published" => ghnative.Trigger.ReleaseType.Published + case "prereleased" => ghnative.Trigger.ReleaseType.Prereleased + }) + case PullRequest(branches, ignoredBranches) => + ghnative.Trigger.PullRequest( + Some(branches.map(_.toNative)).filter(_.nonEmpty), + Some(ignoredBranches.map(_.toNative)).filter(_.nonEmpty) + ) + case Push(branches, ignoredBranches) => + ghnative.Trigger.Push( + Some(branches.map(_.toNative)).filter(_.nonEmpty), + Some(ignoredBranches.map(_.toNative)).filter(_.nonEmpty) + ) + case Create(branches, ignoredBranches) => + ghnative.Trigger.Create( + Some(branches.map(_.toNative)).filter(_.nonEmpty), + Some(ignoredBranches.map(_.toNative)).filter(_.nonEmpty) + ) + } } - - implicit val codec: JsonCodec[Triggers] = DeriveJsonCodec.gen[Triggers] } -@jsonMemberNames(KebabCase) case class Strategy(matrix: Map[String, List[String]], maxParallel: Option[Int] = None, failFast: Boolean = true) object Strategy { - implicit val codec: JsonCodec[Strategy] = DeriveJsonCodec.gen[Strategy] + implicit class StrategyOps(val strategy: Strategy) extends AnyVal { + def toNative: ghnative.Strategy = + ghnative.Strategy( + matrix = strategy.matrix.map { case (key, values) => key -> values }, + maxParallel = strategy.maxParallel, + failFast = strategy.failFast + ) + } } case class ActionRef(ref: String) + object ActionRef { - implicit val codec: JsonCodec[ActionRef] = JsonCodec.string.transform(ActionRef(_), _.ref) + implicit class ActionRefOps(val actionRef: ActionRef) extends AnyVal { + def toNative: ghnative.ActionRef = ghnative.ActionRef(actionRef.ref) + } } sealed trait Condition { @@ -175,10 +159,6 @@ object Condition { def asString: String = s"$${{ $expression }}" } - object Expression { - implicit val codec: JsonCodec[Expression] = JsonCodec.string.transform(Expression(_), _.asString) - } - case class Function(expression: String) extends Condition { def &&(other: Condition): Condition = throw new IllegalArgumentException("Not supported currently") @@ -189,17 +169,12 @@ object Condition { def asString: String = expression } - object Function { - implicit val codec: JsonCodec[Function] = JsonCodec.string.transform(Function(_), _.expression) + implicit class ConditionOps(val condition: Condition) extends AnyVal { + def toNative: ghnative.Condition = condition match { + case Expression(expression) => ghnative.Condition.Expression(expression) + case Function(expression) => ghnative.Condition.Function(expression) + } } - - implicit val codec: JsonCodec[Condition] = JsonCodec.string.transform( - { - case expression if expression.startsWith("${{") => Expression(expression) - case expression => Function(expression) - }, - _.asString - ) } sealed trait Step { @@ -211,21 +186,17 @@ object Step { name: String, id: Option[String] = None, uses: Option[ActionRef] = None, - `if`: Option[Condition] = None, - `with`: Option[Map[String, String]] = None, + condition: Option[Condition] = None, + parameters: Map[String, Json] = Map.empty, run: Option[String] = None, - env: Option[Map[String, String]] = None + env: Map[String, String] = Map.empty ) extends Step { override def when(condition: Condition): Step = - copy(`if` = Some(condition)) + copy(condition = Some(condition)) override def flatten: Seq[Step.SingleStep] = Seq(this) } - object SingleStep { - implicit val codec: JsonCodec[SingleStep] = DeriveJsonCodec.gen[SingleStep] - } - case class StepSequence(steps: Seq[Step]) extends Step { override def when(condition: Condition): Step = copy(steps = steps.map(_.when(condition))) @@ -234,25 +205,38 @@ object Step { steps.flatMap(_.flatten) } - implicit val codec: JsonCodec[Step] = DeriveJsonCodec.gen[Step] + implicit class StepOps(val step: Step) extends AnyVal { + def toNative: ghnative.Step = step match { + case SingleStep(name, id, uses, condition, parameters, run, env) => + ghnative.Step.SingleStep( + name = name, + id = id, + uses = uses.map(_.toNative), + `if` = condition.map(_.toNative), + `with` = Some(parameters).filter(_.nonEmpty), + run = run, + env = Some(env).filter(_.nonEmpty) + ) + case StepSequence(steps) => + ghnative.Step.StepSequence(steps.map(_.toNative)) + } + } } case class ImageRef(ref: String) + object ImageRef { - implicit val codec: JsonCodec[ImageRef] = JsonCodec.string.transform(ImageRef(_), _.ref) + implicit class ImageRefOps(val imageRef: ImageRef) extends AnyVal { + def toNative: ghnative.ImageRef = ghnative.ImageRef(imageRef.ref) + } } case class ServicePort(inner: Int, outer: Int) + object ServicePort { - implicit val codec: JsonCodec[ServicePort] = JsonCodec.string.transformOrFail( - v => - Try(v.split(":", 2).map(_.toInt).toList) match { - case Success(inner :: outer :: Nil) => Right(ServicePort(inner.toInt, outer.toInt)) - case Success(_) => Left("Invalid service port format: " + v) - case Failure(_) => Left("Invalid service port format: " + v) - }, - sp => s"${sp.inner}:${sp.outer}" - ) + implicit class ServicePortOps(val servicePort: ServicePort) extends AnyVal { + def toNative: ghnative.ServicePort = ghnative.ServicePort(servicePort.inner, servicePort.outer) + } } case class Service( @@ -261,77 +245,97 @@ case class Service( env: Map[String, String] = Map.empty, ports: Seq[ServicePort] = Seq.empty ) + object Service { - implicit val codec: JsonCodec[Service] = DeriveJsonCodec.gen[Service] + implicit class ServiceOps(val service: Service) extends AnyVal { + def toNative: ghnative.Service = ghnative.Service( + name = service.name, + image = service.image.toNative, + env = Some(service.env).filter(_.nonEmpty), + ports = Some(service.ports.map(_.toNative)).filter(_.nonEmpty) + ) + } } -@jsonMemberNames(KebabCase) -case class JobValue( +case class Job( + id: String, name: String, runsOn: String = "ubuntu-latest", - timeoutMinutes: Option[Int] = None, + timeoutMinutes: Int = 30, continueOnError: Boolean = false, strategy: Option[Strategy] = None, - needs: Option[Seq[String]] = None, - services: Option[Seq[Service]] = None, - `if`: Option[Condition] = None, - steps: Seq[Step.SingleStep] = Seq.empty + steps: Seq[Step] = Seq.empty, + need: Seq[String] = Seq.empty, + services: Seq[Service] = Seq.empty, + condition: Option[Condition] = None ) { - def withStrategy(strategy: Strategy): JobValue = + def withStrategy(strategy: Strategy): Job = copy(strategy = Some(strategy)) - def withSteps(steps: Step*): JobValue = steps match { - case steps: Step.StepSequence => - copy(steps = steps.flatten) - case step: Step.SingleStep => - copy(steps = step :: Nil) - } + def withSteps(steps: Step*): Job = + copy(steps = steps) - def withServices(services: Service*): JobValue = - copy(services = Some(services)) + def withServices(services: Service*): Job = + copy(services = services) } -object JobValue { - implicit val codec: JsonCodec[JobValue] = DeriveJsonCodec.gen[JobValue] -} - -@jsonMemberNames(KebabCase) -case class Concurrency( - group: String, - cancelInProgress: Boolean = true -) - -object Concurrency { - implicit val codec: JsonCodec[Concurrency] = DeriveJsonCodec.gen[Concurrency] +object Job { + implicit class JobOps(val job: Job) extends AnyVal { + def toNative: ghnative.Job = ( + job.id, + ghnative.JobValue( + name = job.name, + runsOn = job.runsOn, + timeoutMinutes = Some(job.timeoutMinutes), + continueOnError = job.continueOnError, + strategy = job.strategy.map(_.toNative), + steps = job.steps.map(_.toNative).flatMap(_.flatten), + needs = Some(job.need), + services = Some(job.services.map(_.toNative)).filter(_.nonEmpty), + `if` = job.condition.map(_.toNative) + ) + ) + } } case class Workflow( name: String, - env: ListMap[String, String] = ListMap.empty, - on: Option[Triggers] = None, - concurrency: Concurrency = Concurrency( - "${{ github.workflow }}-${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && github.run_id || github.ref }}" - ), - jobs: ListMap[String, JobValue] = ListMap.empty + env: Map[String, String] = Map.empty, + triggers: Seq[Trigger] = Seq.empty, + jobs: Seq[Job] = Seq.empty ) { - def withOn(on: Triggers): Workflow = - copy(on = Some(on)) + def on(triggers: Trigger*): Workflow = + copy(triggers = triggers) - def withJobs(jobs: (String, JobValue)*): Workflow = - copy(jobs = ListMap(jobs: _*)) + def withJobs(jobs: Job*): Workflow = + copy(jobs = jobs) - def addJob(job: (String, JobValue)): Workflow = - copy(jobs = jobs + job) + def addJob(job: Job): Workflow = + copy(jobs = jobs :+ job) - def addJobs(newJobs: (String, JobValue)*): Workflow = + def addJobs(newJobs: Seq[Job]): Workflow = copy(jobs = jobs ++ newJobs) } object Workflow { - implicit def listMapCodec[K: JsonFieldDecoder: JsonFieldEncoder, V: JsonCodec]: JsonCodec[ListMap[K, V]] = - JsonCodec( - JsonEncoder.keyValueIterable[K, V, ListMap], - JsonDecoder.keyValueChunk[K, V].map(c => ListMap(c: _*)) + implicit class WorkflowOps(val workflow: Workflow) extends AnyVal { + def toNative: ghnative.Workflow = ghnative.Workflow( + name = workflow.name, + env = Some(ListMap.empty ++ workflow.env).filter(_.nonEmpty), + on = { + val triggers = workflow.triggers.map(_.toNative) + Some( + ghnative.Triggers( + workflowDispatch = triggers.collectFirst { case t: ghnative.Trigger.WorkflowDispatch => t } + .getOrElse(ghnative.Trigger.WorkflowDispatch()), + release = triggers.collectFirst { case t: ghnative.Trigger.Release => t }, + pullRequest = triggers.collectFirst { case t: ghnative.Trigger.PullRequest => t }, + push = triggers.collectFirst { case t: ghnative.Trigger.Push => t }, + create = triggers.collectFirst { case t: ghnative.Trigger.Create => t } + ) + ).filter(_ => triggers.nonEmpty) + }, + jobs = ListMap(workflow.jobs.map(_.toNative): _*) ) - implicit val codec: JsonCodec[Workflow] = DeriveJsonCodec.gen[Workflow] + } } diff --git a/zio-sbt-githubactions/src/main/scala/zio/sbt/githubactions/package.scala b/zio-sbt-githubactions/src/main/scala/zio/sbt/githubactions/package.scala deleted file mode 100644 index 9f1e009c..00000000 --- a/zio-sbt-githubactions/src/main/scala/zio/sbt/githubactions/package.scala +++ /dev/null @@ -1,5 +0,0 @@ -package zio.sbt - -package object githubactions { - type Job = (String, JobValue) -} diff --git a/zio-sbt-githubactions/src/main/scala/zio/sbt/githubactionsnative/ScalaWorkflow.scala b/zio-sbt-githubactions/src/main/scala/zio/sbt/githubactionsnative/ScalaWorkflow.scala new file mode 100644 index 00000000..64865c93 --- /dev/null +++ b/zio-sbt-githubactions/src/main/scala/zio/sbt/githubactionsnative/ScalaWorkflow.scala @@ -0,0 +1,266 @@ +/* + * Copyright 2022-2023 dev.zio + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.sbt.githubactionsnative + +import zio.json._ +import zio.sbt.githubactionsnative.ScalaWorkflow.JavaVersion.JDK11 + +// The original code of the githubactions package was originally copied from the zio-aws-codegen project: +// https://github.com/zio/zio-aws/tree/master/zio-aws-codegen/src/main/scala/zio/aws/codegen/githubactions +object ScalaWorkflow { + import Step._ + + def checkoutCurrentBranch(fetchDepth: Int = 0): Step = + SingleStep( + name = "Checkout current branch", + uses = Some(ActionRef("actions/checkout@v2")), + `with` = Some( + Map( + "fetch-depth" -> fetchDepth.toJsonAST.right.get + ) + ) + ) + + def setupScala(javaVersion: Option[JavaVersion] = None): Step = + SingleStep( + name = "Setup Java and Scala", + uses = Some(ActionRef("olafurpg/setup-scala@v11")), + `with` = Some( + Map( + "java-version" -> (javaVersion match { + case None => "${{ matrix.java }}" + case Some(version) => version.asString + }).toJsonAST.right.get + ) + ) + ) + + def setupNode(javaVersion: Option[JavaVersion] = None): Step = + SingleStep( + name = "Setup NodeJS", + uses = Some(ActionRef("actions/setup-node@v3")), + `with` = Some( + Map( + "node-version" -> (javaVersion match { + case None => "16.x" + case Some(version) => version.asString + }).toJsonAST.right.get, + "registry-url" -> "https://registry.npmjs.org".toJsonAST.right.get + ) + ) + ) + + def setupGPG(): Step = + SingleStep( + "Setup GPG", + uses = Some(ActionRef("olafurpg/setup-gpg@v3")) + ) + + def cacheSBT( + os: Option[OS] = None, + scalaVersion: Option[ScalaVersion] = None + ): Step = { + val osS = os.map(_.asString).getOrElse("${{ matrix.os }}") + val scalaS = scalaVersion.map(_.version).getOrElse("${{ matrix.scala }}") + + SingleStep( + name = "Cache SBT", + uses = Some(ActionRef("actions/cache@v2")), + `with` = Some( + Map( + "path" -> Seq( + "~/.ivy2/cache", + "~/.sbt", + "~/.coursier/cache/v1", + "~/.cache/coursier/v1" + ).mkString("\n").toJsonAST.right.get, + "key" -> s"$osS-sbt-$scalaS-$${{ hashFiles('**/*.sbt') }}-$${{ hashFiles('**/build.properties') }}".toJsonAST.right.get + ) + ) + ) + } + + def setupGitUser(): Step = + SingleStep( + name = "Setup GIT user", + uses = Some(ActionRef("fregante/setup-git-user@v1")) + ) + + def runSBT( + name: String, + parameters: List[String], + heapGb: Int = 6, + stackMb: Int = 16, + env: Map[String, String] = Map.empty + ): Step = + SingleStep( + name, + run = Some( + s"sbt -J-XX:+UseG1GC -J-Xmx${heapGb}g -J-Xms${heapGb}g -J-Xss${stackMb}m ${parameters.mkString(" ")}" + ), + env = Some(env).filter(_.nonEmpty) + ) + + def storeTargets( + id: String, + directories: List[String], + os: Option[OS] = None, + scalaVersion: Option[ScalaVersion] = None, + javaVersion: Option[JavaVersion] = None + ): Step = { + val osS = os.map(_.asString).getOrElse("${{ matrix.os }}") + val scalaS = scalaVersion.map(_.version).getOrElse("${{ matrix.scala }}") + val javaS = javaVersion.map(_.asString).getOrElse("${{ matrix.java }}") + + StepSequence( + Seq( + SingleStep( + s"Compress $id targets", + run = Some( + s"tar cvf targets.tar ${directories.map(dir => s"$dir/target".dropWhile(_ == '/')).mkString(" ")}" + ) + ), + SingleStep( + s"Upload $id targets", + uses = Some(ActionRef("actions/upload-artifact@v2")), + `with` = Some( + Map( + "name" -> s"target-$id-$osS-$scalaS-$javaS".toJsonAST.right.get, + "path" -> "targets.tar".toJsonAST.right.get + ) + ) + ) + ) + ) + } + + def loadStoredTarget( + id: String, + os: Option[OS] = None, + scalaVersion: Option[ScalaVersion] = None, + javaVersion: Option[JavaVersion] = None + ): Step = { + val osS = os.map(_.asString).getOrElse("${{ matrix.os }}") + val scalaS = scalaVersion.map(_.version).getOrElse("${{ matrix.scala }}") + val javaS = javaVersion.map(_.asString).getOrElse("${{ matrix.java }}") + + StepSequence( + Seq( + SingleStep( + s"Download stored $id targets", + uses = Some(ActionRef("actions/download-artifact@v2")), + `with` = Some( + Map( + "name" -> s"target-$id-$osS-$scalaS-$javaS".toJsonAST.right.get + ) + ) + ), + SingleStep( + s"Inflate $id targets", + run = Some( + "tar xvf targets.tar\nrm targets.tar" + ) + ) + ) + ) + } + + def loadStoredTargets( + ids: List[String], + os: Option[OS] = None, + scalaVersion: Option[ScalaVersion] = None, + javaVersion: Option[JavaVersion] = None + ): Step = + StepSequence( + ids.map(loadStoredTarget(_, os, scalaVersion, javaVersion)) + ) + + def loadPGPSecret(): Step = + SingleStep( + "Load PGP secret", + run = Some(".github/import-key.sh"), + env = Some(Map("PGP_SECRET" -> "${{ secrets.PGP_SECRET }}")) + ) + + def turnstyle(): Step = + SingleStep( + "Turnstyle", + uses = Some(ActionRef("softprops/turnstyle@v1")), + env = Some( + Map( + "GITHUB_TOKEN" -> "${{ secrets.ADMIN_GITHUB_TOKEN }}" + ) + ) + ) + + def collectDockerLogs(): Step = + SingleStep( + "Collect Docker logs", + uses = Some(ActionRef("jwalton/gh-docker-logs@v1")) + ) + + val isMaster: Condition = Condition.Expression( + "github.ref == 'refs/heads/master'" + ) + val isNotMaster: Condition = Condition.Expression( + "github.ref != 'refs/heads/master'" + ) + def isScalaVersion(version: ScalaVersion): Condition = Condition.Expression( + s"matrix.scala == '${version.version}'" + ) + def isNotScalaVersion(version: ScalaVersion): Condition = + Condition.Expression( + s"matrix.scala != '${version.version}'" + ) + val isFailure: Condition = Condition.Function("failure()") + + case class ScalaVersion(version: String) + + trait JavaVersion { + val asString: String + } + object JavaVersion { + + case class CorrettoJDK(javaVersion: String) extends JavaVersion { + override val asString: String = s"corretto:$javaVersion" + } + + val JDK11: JavaVersion = CorrettoJDK("11") + val JDK17: JavaVersion = CorrettoJDK("17") + val JDK21: JavaVersion = CorrettoJDK("21") + } + + implicit class JobOps(job: Job) { + def matrix( + scalaVersions: Seq[ScalaVersion], + operatingSystems: Seq[OS] = Seq(OS.UbuntuLatest), + javaVersions: Seq[JavaVersion] = Seq(JDK11) + ): Job = + job._1 -> job._2.copy( + strategy = Some( + Strategy( + matrix = Map( + "os" -> operatingSystems.map(_.asString).toList, + "scala" -> scalaVersions.map(_.version).toList, + "java" -> javaVersions.map(_.asString).toList + ) + ) + ), + runsOn = "${{ matrix.os }}" + ) + } +} diff --git a/zio-sbt-githubactions/src/main/scala/zio/sbt/githubactionsnative/model.scala b/zio-sbt-githubactions/src/main/scala/zio/sbt/githubactionsnative/model.scala new file mode 100644 index 00000000..d7099eda --- /dev/null +++ b/zio-sbt-githubactions/src/main/scala/zio/sbt/githubactionsnative/model.scala @@ -0,0 +1,344 @@ +/* + * Copyright 2022-2023 dev.zio + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package zio.sbt.githubactionsnative + +import scala.collection.immutable.ListMap +import scala.util.{Failure, Success, Try} + +import zio.json._ +import zio.json.ast.Json + +sealed trait OS { + val asString: String +} +object OS { + case object UbuntuLatest extends OS { val asString = "ubuntu-latest" } +} + +sealed trait Branch +object Branch { + case object All extends Branch + case class Named(name: String) extends Branch + + implicit val codec: JsonCodec[Branch] = JsonCodec.string.transform( + { + case "*" => All + case name => Named(name) + }, + { + case All => "*" + case Named(name) => name + } + ) +} + +@jsonMemberNames(SnakeCase) +case class Triggers( + workflowDispatch: Trigger.WorkflowDispatch = Trigger.WorkflowDispatch(), + release: Option[Trigger.Release] = None, + pullRequest: Option[Trigger.PullRequest] = None, + push: Option[Trigger.Push] = None, + create: Option[Trigger.Create] = None +) + +object Triggers { + + implicit val codec: JsonCodec[Triggers] = DeriveJsonCodec.gen[Triggers] +} + +sealed trait Trigger + +object Trigger { + case class InputValue(description: String, required: Boolean, default: String) + object InputValue { + implicit val jsonCodec: JsonCodec[InputValue] = DeriveJsonCodec.gen[InputValue] + } + + case class WorkflowDispatch( + inputs: Option[ListMap[String, InputValue]] = None + ) extends Trigger + + object WorkflowDispatch { + implicit def listMapCodec[K: JsonFieldDecoder: JsonFieldEncoder, V: JsonCodec]: JsonCodec[ListMap[K, V]] = + JsonCodec( + JsonEncoder.keyValueIterable[K, V, ListMap], + JsonDecoder.keyValueChunk[K, V].map(c => ListMap(c: _*)) + ) + + implicit val jsonCodec: JsonCodec[WorkflowDispatch] = DeriveJsonCodec.gen[WorkflowDispatch] + } + + case class Release( + types: Seq[ReleaseType] = Seq.empty + ) extends Trigger + + object Release { + implicit val jsonCodec: JsonCodec[Release] = DeriveJsonCodec.gen[Release] + } + + sealed trait ReleaseType + object ReleaseType { + case object Created extends ReleaseType + case object Published extends ReleaseType + case object Prereleased extends ReleaseType + + implicit val codec: JsonCodec[ReleaseType] = JsonCodec.string.transformOrFail( + { + case "created" => Right(Created) + case "published" => Right(Published) + case "prereleased" => Right(Prereleased) + case other => Left(s"Invalid release type: $other") + }, + { + case Created => "created" + case Published => "published" + case Prereleased => "prereleased" + } + ) + } + + @jsonMemberNames(KebabCase) + case class PullRequest( + // types: Option[Seq[PullRequestType]] = None, + branches: Option[Seq[Branch]] = None, + branchesIgnore: Option[Seq[Branch]] = None, + paths: Option[Seq[String]] = None + ) extends Trigger + + object PullRequest { + implicit val jsonCodec: JsonCodec[PullRequest] = DeriveJsonCodec.gen[PullRequest] + } + + case class Push( + branches: Option[Seq[Branch]] = None, + branchesIgnore: Option[Seq[Branch]] = None + ) extends Trigger + + object Push { + implicit val jsonCodec: JsonCodec[Push] = DeriveJsonCodec.gen[Push] + } + + case class Create( + branches: Option[Seq[Branch]] = None, + branchesIgnore: Option[Seq[Branch]] = None + ) extends Trigger + + object Create { + implicit val jsonCodec: JsonCodec[Create] = DeriveJsonCodec.gen[Create] + } +} + +@jsonMemberNames(KebabCase) +case class Strategy(matrix: Map[String, List[String]], maxParallel: Option[Int] = None, failFast: Boolean = true) + +object Strategy { + implicit val codec: JsonCodec[Strategy] = DeriveJsonCodec.gen[Strategy] +} + +case class ActionRef(ref: String) +object ActionRef { + implicit val codec: JsonCodec[ActionRef] = JsonCodec.string.transform(ActionRef(_), _.ref) +} + +sealed trait Condition { + def &&(other: Condition): Condition + def ||(other: Condition): Condition + def asString: String +} + +object Condition { + case class Expression(expression: String) extends Condition { + def &&(other: Condition): Condition = + other match { + case Expression(otherExpression: String) => + Expression(s"($expression) && ($otherExpression)") + case Function(_: String) => + throw new IllegalArgumentException("Not supported currently") + } + + def ||(other: Condition): Condition = + other match { + case Expression(otherExpression: String) => + Expression(s"($expression) || ($otherExpression)") + case Function(_: String) => + throw new IllegalArgumentException("Not supported currently") + } + + def asString: String = s"$${{ $expression }}" + } + + object Expression { + implicit val codec: JsonCodec[Expression] = JsonCodec.string.transform(Expression(_), _.asString) + } + + case class Function(expression: String) extends Condition { + def &&(other: Condition): Condition = + throw new IllegalArgumentException("Not supported currently") + + def ||(other: Condition): Condition = + throw new IllegalArgumentException("Not supported currently") + + def asString: String = expression + } + + object Function { + implicit val codec: JsonCodec[Function] = JsonCodec.string.transform(Function(_), _.expression) + } + + implicit val codec: JsonCodec[Condition] = JsonCodec.string.transform( + { + case expression if expression.startsWith("${{") => Expression(expression) + case expression => Function(expression) + }, + _.asString + ) +} + +sealed trait Step { + def when(condition: Condition): Step + def flatten: Seq[Step.SingleStep] +} +object Step { + case class SingleStep( + name: String, + id: Option[String] = None, + uses: Option[ActionRef] = None, + `if`: Option[Condition] = None, + `with`: Option[Map[String, Json]] = None, + run: Option[String] = None, + env: Option[Map[String, String]] = None + ) extends Step { + override def when(condition: Condition): Step = + copy(`if` = Some(condition)) + + override def flatten: Seq[Step.SingleStep] = Seq(this) + } + + object SingleStep { + implicit val codec: JsonCodec[SingleStep] = DeriveJsonCodec.gen[SingleStep] + } + + case class StepSequence(steps: Seq[Step]) extends Step { + override def when(condition: Condition): Step = + copy(steps = steps.map(_.when(condition))) + + override def flatten: Seq[SingleStep] = + steps.flatMap(_.flatten) + } + + implicit val codec: JsonCodec[Step] = DeriveJsonCodec.gen[Step] +} + +case class ImageRef(ref: String) +object ImageRef { + implicit val codec: JsonCodec[ImageRef] = JsonCodec.string.transform(ImageRef(_), _.ref) +} + +case class ServicePort(inner: Int, outer: Int) +object ServicePort { + implicit val codec: JsonCodec[ServicePort] = JsonCodec.string.transformOrFail( + v => + Try(v.split(":", 2).map(_.toInt).toList) match { + case Success(inner :: outer :: Nil) => Right(ServicePort(inner.toInt, outer.toInt)) + case Success(_) => Left("Invalid service port format: " + v) + case Failure(_) => Left("Invalid service port format: " + v) + }, + sp => s"${sp.inner}:${sp.outer}" + ) +} + +case class Service( + name: String, + image: ImageRef, + env: Option[Map[String, String]] = None, + ports: Option[Seq[ServicePort]] = None +) +object Service { + implicit val codec: JsonCodec[Service] = DeriveJsonCodec.gen[Service] +} + +@jsonMemberNames(KebabCase) +case class JobValue( + name: String, + runsOn: String = "ubuntu-latest", + timeoutMinutes: Option[Int] = None, + continueOnError: Boolean = false, + strategy: Option[Strategy] = None, + needs: Option[Seq[String]] = None, + services: Option[Seq[Service]] = None, + `if`: Option[Condition] = None, + steps: Seq[Step.SingleStep] = Seq.empty +) { + def withStrategy(strategy: Strategy): JobValue = + copy(strategy = Some(strategy)) + + def withSteps(steps: Step*): JobValue = steps match { + case steps: Step.StepSequence => + copy(steps = steps.flatten) + case step: Step.SingleStep => + copy(steps = step :: Nil) + } + + def withServices(services: Service*): JobValue = + copy(services = Some(services)) +} + +object JobValue { + implicit val codec: JsonCodec[JobValue] = DeriveJsonCodec.gen[JobValue] +} + +@jsonMemberNames(KebabCase) +case class Concurrency( + group: String, + cancelInProgress: Boolean = true +) + +object Concurrency { + implicit val codec: JsonCodec[Concurrency] = DeriveJsonCodec.gen[Concurrency] +} + +case class Workflow( + name: String, + env: Option[ListMap[String, String]] = None, + on: Option[Triggers] = None, + concurrency: Concurrency = Concurrency( + "${{ github.workflow }}-${{ github.ref == format('refs/heads/{0}', github.event.repository.default_branch) && github.run_id || github.ref }}" + ), + jobs: ListMap[String, JobValue] = ListMap.empty +) { + def withOn(on: Triggers): Workflow = + copy(on = Some(on)) + + def withJobs(jobs: (String, JobValue)*): Workflow = + copy(jobs = ListMap(jobs: _*)) + + def addJob(job: (String, JobValue)): Workflow = + copy(jobs = jobs + job) + + def addJobs(newJobs: (String, JobValue)*): Workflow = + copy(jobs = jobs ++ newJobs) +} + +object Workflow { + + implicit def listMapCodec[K: JsonFieldDecoder: JsonFieldEncoder, V: JsonCodec]: JsonCodec[ListMap[K, V]] = + JsonCodec( + JsonEncoder.keyValueIterable[K, V, ListMap], + JsonDecoder.keyValueChunk[K, V].map(c => ListMap(c: _*)) + ) + implicit val codec: JsonCodec[Workflow] = DeriveJsonCodec.gen[Workflow] +} diff --git a/zio-sbt-githubactions/src/main/scala/zio/sbt/githubactionsnative/package.scala b/zio-sbt-githubactions/src/main/scala/zio/sbt/githubactionsnative/package.scala new file mode 100644 index 00000000..b9efa33e --- /dev/null +++ b/zio-sbt-githubactions/src/main/scala/zio/sbt/githubactionsnative/package.scala @@ -0,0 +1,60 @@ +package zio.sbt + +package object githubactionsnative { + type Job = (String, JobValue) + + object Job { + def apply( + id: String, + name: String, + runsOn: String = "ubuntu-latest", + timeoutMinutes: Option[Int] = None, + continueOnError: Boolean = false, + strategy: Option[Strategy] = None, + needs: Option[Seq[String]] = None, + services: Option[Seq[Service]] = None, + `if`: Option[Condition] = None, + steps: Seq[Step.SingleStep] = Seq.empty + ): Job = id -> JobValue( + name, + runsOn, + timeoutMinutes, + continueOnError, + strategy, + needs, + services, + `if`, + steps + ) + } + + implicit class JobOps(job: Job) { + + def withRunsOn(runsOn: String): Job = + job._1 -> job._2.copy(runsOn = runsOn) + + def withName(name: String): Job = + job._1 -> job._2.copy(name = name) + + def withTimeoutMinutes(timeoutMinutes: Option[Int]): Job = + job._1 -> job._2.copy(timeoutMinutes = timeoutMinutes) + + def withContinueOnError(continueOnError: Boolean): Job = + job._1 -> job._2.copy(continueOnError = continueOnError) + + def withStrategy(strategy: Option[Strategy]): Job = + job._1 -> job._2.copy(strategy = strategy) + + def withNeeds(needs: Option[Seq[String]]): Job = + job._1 -> job._2.copy(needs = needs) + + def withStrategy(strategy: Strategy): Job = + job._1 -> job._2.withStrategy(strategy) + + def withServices(services: Service*): Job = + job._1 -> job._2.withServices(services: _*) + + def withSteps(steps: Seq[Step.SingleStep]): Job = + job._1 -> job._2.withSteps(steps: _*) + } +}