Skip to content
This repository has been archived by the owner on Oct 25, 2024. It is now read-only.

Pitgull bootstrap cli #264

Merged
merged 26 commits into from
Jun 12, 2021
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
8967c1c
getting started, issues with CE3 in Scala 3
majk-p May 31, 2021
a0992d9
fix build
majk-p May 31, 2021
628ec6f
add merge requests querry
majk-p May 31, 2021
ccc3b5d
logger
majk-p Jun 5, 2021
e906f5a
implement gitlab client
majk-p Jun 8, 2021
4be5d69
fix workflow
majk-p Jun 8, 2021
c7d209a
close qualified merge requests
majk-p Jun 8, 2021
a250e9c
cleanup
majk-p Jun 8, 2021
8627f8a
clean up build.sbt
majk-p Jun 8, 2021
9ff5c41
delete MR instead of closing it, implement webhook creation API
majk-p Jun 9, 2021
e73fb2f
exclude bootstrap from publishing artifact
majk-p Jun 9, 2021
b5afe33
cleanup
majk-p Jun 9, 2021
d1051a6
fix webhook creation
majk-p Jun 9, 2021
d929b29
build native image in github actions
majk-p Jun 9, 2021
b778495
use workflow from https://github.com/scalameta/sbt-native-image\#gene…
majk-p Jun 9, 2021
2ac8fd9
add better-tostring, fix reflection config in native-image
majk-p Jun 11, 2021
45a7e70
revert to using sbt workflow configuration instead of native.yml
majk-p Jun 11, 2021
6f3e26f
change fixme to link an issue
majk-p Jun 11, 2021
3f83775
wait for user consent before deleting merge requests
majk-p Jun 11, 2021
a6c3ded
extend note about blocked decline for argument parsing
majk-p Jun 11, 2021
66c1ad4
add usage info to both program and readme.md
majk-p Jun 11, 2021
3c2c6fe
Update README.md
majk-p Jun 12, 2021
a97d0c8
use cats-effect Console, configure scalafmt for scala 3, format Main
majk-p Jun 12, 2021
ae4c417
update reflect-config for native-image, apply formatting, some cleanup
majk-p Jun 12, 2021
45f6991
update readme - add reasoning behind MR deletion
majk-p Jun 12, 2021
2d5d4b1
scalafmt
majk-p Jun 12, 2021
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions .github/workflows/native.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
name: Native Image
on:
push:
branches:
- master
- main
pull_request:
release:
types: [published]
jobs:
unix:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [macOS-latest, ubuntu-latest, windows-latest]
include:
- os: macOS-latest
uploaded_filename: bootstrap-x86_64-apple-darwin
local_path: bootstrap/target/native-image/bootstrap
- os: ubuntu-latest
uploaded_filename: bootstrap-x86_64-pc-linux
local_path: bootstrap/target/native-image/bootstrap
- os: windows-latest
uploaded_filename: bootstrap-x86_64-pc-win32.exe
local_path: bootstrap\target\native-image\bootstrap.exe
steps:
- uses: actions/checkout@v2
- uses: olafurpg/setup-scala@v10
- run: git fetch --tags || true
- run: sbt bootstrap/nativeImage
shell: bash
if: ${{ matrix.os != 'windows-latest' }}
- run: echo $(pwd)
shell: bash
- name: sbt test
shell: cmd
if: ${{ matrix.os == 'windows-latest' }}
run: >-
"C:\Program Files (x86)\Microsoft Visual
Studio\2019\Enterprise\VC\Auxiliary\Build\vcvars64.bat" && sbt
bootstrap/nativeImage
- uses: actions/upload-artifact@v2
with:
path: ${{ matrix.local_path }}
name: ${{ matrix.uploaded_filename }}
- name: Upload release
if: github.event_name == 'release'
uses: actions/upload-release-asset@v1.0.2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ github.event.release.upload_url }}
asset_path: ${{ matrix.local_path }}
asset_name: ${{ matrix.uploaded_filename }}
asset_content_type: application/zip
21 changes: 21 additions & 0 deletions bootstrap/src/main/scala/org/polyvariant/Args.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package org.polyvariant

object Args {
private val switch = "-(\\w+)".r
private val option = "--(\\w+)".r

private def parseNext(pendingArguments: List[String], previousResult: Map[String, String]): Map[String, String] =
pendingArguments match {
case Nil => previousResult
case option(opt) :: value :: tail => parseNext(tail, previousResult ++ Map(opt -> value))
case switch(opt) :: tail => parseNext(tail, previousResult ++ Map(opt -> null))
case text :: Nil => previousResult ++ Map(text -> null)
case text :: tail => parseNext(tail, previousResult ++ Map(text -> null))
}


// TODO: Consider switching to https://ben.kirw.in/decline/
majk-p marked this conversation as resolved.
Show resolved Hide resolved
def parse(args: List[String]): Map[String, String] =
parseNext(args.toList, Map())

}
151 changes: 151 additions & 0 deletions bootstrap/src/main/scala/org/polyvariant/Gitlab.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
package org.polyvariant

import cats.implicits.*

import scala.util.chaining._
import io.pg.gitlab.graphql.*
import sttp.model.Uri
import sttp.client3.*
import caliban.client.SelectionBuilder
import caliban.client.CalibanClientError.DecodingError
import io.pg.gitlab.graphql.MergeRequest
import io.pg.gitlab.graphql.MergeRequestConnection
import io.pg.gitlab.graphql.MergeRequestState
import io.pg.gitlab.graphql.Pipeline
import io.pg.gitlab.graphql.PipelineStatusEnum
import io.pg.gitlab.graphql.Project
import io.pg.gitlab.graphql.ProjectConnection
import io.pg.gitlab.graphql.Query
import io.pg.gitlab.graphql.UserCore
import caliban.client.Operations.IsOperation
import sttp.model.Method
import cats.MonadThrow

trait Gitlab[F[_]] {
def mergeRequests(projectId: Long): F[List[Gitlab.MergeRequestInfo]]
def deleteMergeRequest(projectId: Long, mergeRequestId: Long): F[Unit]
def createWebhook(projectId: Long, pitgullUrl: Uri): F[Unit]
}

object Gitlab {

def sttpInstance[F[_]: Logger: MonadThrow](
baseUri: Uri,
accessToken: String
)(
using backend: SttpBackend[Identity, Any] // FIXME: all cats-effect compatible backends rely on Netty, while netty breaks native-image build
): Gitlab[F] = {
def runRequest[O](request: Request[O, Any]): F[O] =
request.header("Private-Token", accessToken).send(backend).pure[F].map(_.body) // FIXME - change to async backend

def runGraphQLQuery[A: IsOperation, B](a: SelectionBuilder[A, B]): F[B] =
runRequest(a.toRequest(baseUri.addPath("api", "graphql"))).rethrow

new Gitlab[F] {
def mergeRequests(projectId: Long): F[List[MergeRequestInfo]] =
Logger[F].info(s"Looking up merge requests for project: $projectId") *>
mergeRequestsQuery(projectId)
.mapEither(_.toRight(DecodingError("Project not found")))
.pipe(runGraphQLQuery(_))
.flatTap { result =>
Logger[F].info(s"Found merge requests. Size: ${result.size}")
}

def deleteMergeRequest(projectId: Long, mergeRequestId: Long): F[Unit] = for {
_ <- Logger[F].debug(s"Request to remove $mergeRequestId")
result <- runRequest(
basicRequest.delete(
baseUri
.addPath(
Seq(
"api",
"v4",
"projects",
projectId.toString,
"merge_requests",
mergeRequestId.toString
)
)
)
)
} yield ()

def createWebhook(projectId: Long, pitgullUrl: Uri): F[Unit] = for {
_ <- Logger[F].debug(s"Creating webhook to $pitgullUrl")
result <- runRequest(
basicRequest.post(
baseUri
.addPath(
Seq(
"api",
"v4",
"projects",
projectId.toString,
"hooks"
)
)
)
.body(s"""{"merge_requests_events": true, "pipeline_events": true, "note_events": true, "url": "$pitgullUrl"}""")
.contentType("application/json")
)
} yield ()
}

}

final case class MergeRequestInfo(
projectId: Long,
mergeRequestIid: Long,
authorUsername: String,
description: Option[String],
needsRebase: Boolean,
hasConflicts: Boolean
)

private def flattenTheEarth[A]: Option[List[Option[Option[Option[List[Option[A]]]]]]] => List[A] =
_.toList.flatten.flatten.flatten.flatten.flatten.flatten

private def mergeRequestInfoSelection(projectId: Long): SelectionBuilder[MergeRequest, MergeRequestInfo] = (
MergeRequest.iid.mapEither(_.toLongOption.toRight(DecodingError("MR IID wasn't a Long"))) ~
MergeRequest
.author(UserCore.username)
.mapEither(_.toRight(DecodingError("MR has no author"))) ~
MergeRequest.description ~
MergeRequest.shouldBeRebased ~
MergeRequest.conflicts
).mapN((buildMergeRequest(projectId) _))

private def buildMergeRequest(
projectId: Long
)(
mergeRequestIid: Long,
authorUsername: String,
description: Option[String],
needsRebase: Boolean,
hasConflicts: Boolean
): MergeRequestInfo = MergeRequestInfo(
projectId = projectId,
mergeRequestIid = mergeRequestIid,
authorUsername = authorUsername,
description = description,
needsRebase = needsRebase,
hasConflicts = hasConflicts
)

private def mergeRequestsQuery(projectId: Long) =
Query
.projects(ids = List(show"gid://gitlab/Project/$projectId").some)(
ProjectConnection
.nodes(
Project
.mergeRequests(
state = MergeRequestState.opened.some
)(
MergeRequestConnection
.nodes(mergeRequestInfoSelection(projectId))
)
)
.map(flattenTheEarth)
)

}
28 changes: 28 additions & 0 deletions bootstrap/src/main/scala/org/polyvariant/Logger.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package org.polyvariant

import cats.syntax.apply
import cats.effect.kernel.Sync
import scala.io.AnsiColor._

trait Logger[F[_]] {
def debug(msg: String): F[Unit]
def success(msg: String): F[Unit]
def info(msg: String): F[Unit]
def warn(msg: String): F[Unit]
def error(msg: String): F[Unit]
}

object Logger {
def apply[F[_]](using ev: Logger[F]): Logger[F] = ev

def wrappedPrint[F[_]: Sync] = new Logger[F] {
private def colorPrinter(color: String)(msg: String): F[Unit] =
Sync[F].delay(println(s"${color}${msg}${RESET}"))

override def debug(msg: String): F[Unit] = colorPrinter(CYAN)(msg)
override def success(msg: String): F[Unit] = colorPrinter(GREEN)(msg)
override def info(msg: String): F[Unit] = colorPrinter(WHITE)(msg)
override def warn(msg: String): F[Unit] = colorPrinter(YELLOW)(msg)
override def error(msg: String): F[Unit] = colorPrinter(RED)(msg)
}
}
60 changes: 60 additions & 0 deletions bootstrap/src/main/scala/org/polyvariant/Main.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package org.polyvariant

import cats.implicits.*
import cats.effect.*

import sttp.model.Uri

import sttp.client3.*
import org.polyvariant.Gitlab.MergeRequestInfo
import cats.Applicative
import sttp.monad.MonadError

object Main extends IOApp {

private def printMergeRequests[F[_]: Logger: Applicative](mergeRequests: List[MergeRequestInfo]): F[Unit] =
mergeRequests.traverse { mr =>
Logger[F].info(s"ID: ${mr.mergeRequestIid} by: ${mr.authorUsername}")
}.void

private def qualifyMergeRequestsForDeletion(botUserName: String, mergeRequests: List[MergeRequestInfo]): List[MergeRequestInfo] =
mergeRequests.filter(_.authorUsername == botUserName)

private def program[F[_]: Logger: Async](args: List[String]): F[Unit] =
given backend: SttpBackend[Identity, Any] = HttpURLConnectionBackend()
majk-p marked this conversation as resolved.
Show resolved Hide resolved
for {
_ <- Logger[F].info("Starting pitgull bootstrap!")
parsedArgs = Args.parse(args)
config = Config.fromArgs(parsedArgs)
gitlab = Gitlab.sttpInstance[F](config.gitlabUri, config.token)
mrs <- gitlab.mergeRequests(config.project)
_ <- Logger[F].info(s"Merge requests found: ${mrs.length}")
_ <- printMergeRequests(mrs)
botMrs = qualifyMergeRequestsForDeletion(config.botUser, mrs)
_ <- Logger[F].info(s"Will delete merge requests: ${botMrs.map(_.mergeRequestIid)}")
majk-p marked this conversation as resolved.
Show resolved Hide resolved
_ <- botMrs.traverse(mr => gitlab.deleteMergeRequest(config.project, mr.mergeRequestIid))
_ <- Logger[F].info("Done processing merge requests")
_ <- Logger[F].info("Creating webhook")
_ <- gitlab.createWebhook(config.project, config.pitgullWebhookUrl)
_ <- Logger[F].info("Webhook created")
_ <- Logger[F].success("Bootstrap finished")
} yield ()

override def run(args: List[String]): IO[ExitCode] = {
given Logger[IO] = Logger.wrappedPrint[IO]
program[IO](args) *>
IO.pure(ExitCode.Success)
}

final case class Config(
gitlabUri: Uri,
token: String,
project: Long,
botUser: String,
pitgullWebhookUrl: Uri
)
object Config {
def fromArgs(args: Map[String, String]): Config = // FIXME: this is unsafe
Config(Uri.unsafeParse(args("url")), args("token"), args("project").toLong, args("bot"), Uri.unsafeParse(args("webhook")))
majk-p marked this conversation as resolved.
Show resolved Hide resolved
}
}
19 changes: 18 additions & 1 deletion build.sbt
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import com.typesafe.sbt.packager.docker.ExecCmd
import com.typesafe.sbt.packager.docker.Cmd
import com.typesafe.sbt.packager.docker.ExecCmd

inThisBuild(
List(
Expand Down Expand Up @@ -100,6 +100,23 @@ lazy val gitlab = project
)
.dependsOn(core)

lazy val bootstrap = project
majk-p marked this conversation as resolved.
Show resolved Hide resolved
.settings(
majk-p marked this conversation as resolved.
Show resolved Hide resolved
scalaVersion := "3.0.0",
libraryDependencies ++= List(
"org.typelevel" %% "cats-core" % "2.6.1",
"org.typelevel" %% "cats-effect" % "3.1.1",
"com.kubukoz" %% "caliban-gitlab" % "0.1.0",
"com.softwaremill.sttp.client3" %% "core" % "3.3.6"
),
testFrameworks += new TestFramework("weaver.framework.TestFramework"),
majk-p marked this conversation as resolved.
Show resolved Hide resolved
publish / skip := true,
Compile / mainClass := Some("org.polyvariant.Main"),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this made me realize we should have a sub-package there. org.polyvariant.bootstrap or io.pg.bootstrap for consistency with the rest :)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about renaming this one to org.polyvariant.pitgull.bootstrap and creating ticket to repackage io.pg in main application to org.polyvariant.pitgull.app?

Copy link
Member

@kubukoz kubukoz Jun 11, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

convince me why that's better and we can do it :D

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's better because:

  • We can avoid confusion - package name matches the organization name
  • Someone else owns pg.io, and it's registered till 2027
Domain Name: PG.IO
Registry Domain ID: D503300000040413631-LRMS
...
Creation Date: 2003-09-22T14:07:39Z
Registry Expiry Date: 2027-04-30T12:00:00Z

I'd say let's leave the package name unchanged for now, and sort this out in separate PR.

scalacOptions --= Seq("-source", "future"),
majk-p marked this conversation as resolved.
Show resolved Hide resolved
githubWorkflowArtifactUpload := false
)
.enablePlugins(NativeImagePlugin)

lazy val core = project.settings(commonSettings).settings(name += "-core")

//workaround for docker not accepting + (the default separator in sbt-dynver)
Expand Down
1 change: 1 addition & 0 deletions project/plugins.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ addSbtPlugin("com.codecommit" % "sbt-github-actions" % "0.12.0")
addSbtPlugin("com.eed3si9n" % "sbt-buildinfo" % "0.10.0")
addSbtPlugin("com.dwijnand" % "sbt-dynver" % "4.1.1")
addSbtPlugin("ch.epfl.scala" % "sbt-missinglink" % "0.3.2")
addSbtPlugin("org.scalameta" % "sbt-native-image" % "0.3.0")