Skip to content

The dotty-bot #1963

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Feb 13, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
23 changes: 23 additions & 0 deletions bot/src/dotty/tools/bot/Main.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package dotty.tools.bot

import org.http4s.server.{ Server, ServerApp }
import org.http4s.server.blaze._

import scalaz.concurrent.Task

object Main extends ServerApp with PullRequestService {

val user = sys.env("USER")
val token = sys.env("TOKEN")
val port = sys.env("PORT").toInt

/** Services mounted to the server */
final val services = prService

override def server(args: List[String]): Task[Server] = {
BlazeBuilder
.bindHttp(port, "0.0.0.0")
.mountService(services, "/api")
.start
}
}
189 changes: 189 additions & 0 deletions bot/src/dotty/tools/bot/PullRequestService.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
package dotty.tools.bot

import org.http4s.{ Status => _, _ }
import org.http4s.client.blaze._
import org.http4s.client.Client
import org.http4s.headers.Authorization

import cats.syntax.applicative._
import scalaz.concurrent.Task
import scala.util.control.NonFatal
import scala.Function.tupled

import io.circe._
import io.circe.generic.auto._
import io.circe.syntax._
import org.http4s.circe._
import org.http4s.dsl._
import org.http4s.util._

import model.Github._

object TaskIsApplicative {
implicit val taskIsApplicative = new cats.Applicative[Task] {
def pure[A](x: A): Task[A] = Task.now(x)
def ap[A, B](ff: Task[A => B])(fa: Task[A]): Task[B] =
for(f <- ff; a <- fa) yield f(a)
}
}
import TaskIsApplicative._

trait PullRequestService {

/** Username for authorized admin */
def user: String

/** OAuth token needed for user to create statuses */
def token: String

/** Pull Request HTTP service */
val prService = HttpService {
case request @ POST -> Root =>
request.as(jsonOf[Issue]).flatMap(checkPullRequest)
}

private[this] lazy val authHeader = {
val creds = BasicCredentials(user, token)
new Authorization(creds)
}

private final case class CLASignature(
user: String,
signed: Boolean,
version: String,
currentVersion: String
)

def claUrl(userName: String): String =
s"https://www.lightbend.com/contribute/cla/scala/check/$userName"

def commitsUrl(prNumber: Int): String =
s"https://api.github.com/repos/lampepfl/dotty/pulls/$prNumber/commits?per_page=100"

def statusUrl(sha: String): String =
s"https://api.github.com/repos/lampepfl/dotty/statuses/$sha"

def toUri(url: String): Task[Uri] =
Uri.fromString(url).fold(Task.fail, Task.now)

def getRequest(endpoint: Uri): Request =
Request(uri = endpoint, method = Method.GET).putHeaders(authHeader)

def postRequest(endpoint: Uri): Request =
Request(uri = endpoint, method = Method.POST).putHeaders(authHeader)

def shutdownClient(client: Client): Unit =
client.shutdownNow()

sealed trait CommitStatus {
def commit: Commit
def isValid: Boolean
}
final case class Valid(user: String, commit: Commit) extends CommitStatus { def isValid = true }
final case class Invalid(user: String, commit: Commit) extends CommitStatus { def isValid = false }
final case class CLAServiceDown(user: String, commit: Commit) extends CommitStatus { def isValid = false }
final case class MissingUser(commit: Commit) extends CommitStatus { def isValid = false }

/** Partitions invalid and valid commits */
def checkCLA(xs: List[Commit], httpClient: Client): Task[List[CommitStatus]] = {
def checkUser(user: String): Task[Commit => CommitStatus] = {
val claStatus = for {
endpoint <- toUri(claUrl(user))
claReq <- getRequest(endpoint).pure[Task]
claRes <- httpClient.expect(claReq)(jsonOf[CLASignature])
} yield { (commit: Commit) =>
if (claRes.signed) Valid(user, commit)
else Invalid(user, commit)
}

claStatus.handleWith {
case NonFatal(e) =>
println(e)
Task.now((commit: Commit) => CLAServiceDown(user, commit))
}
}

def checkCommit(author: Author, commit: List[Commit]): Task[List[CommitStatus]] =
author.login.map(checkUser)
.getOrElse(Task.now(MissingUser))
.map(f => commit.map(f))

Task.gatherUnordered {
val groupedByAuthor: Map[Author, List[Commit]] = xs.groupBy(_.author)
groupedByAuthor.map(tupled(checkCommit)).toList
}.map(_.flatten)
}

def sendStatuses(xs: List[CommitStatus], httpClient: Client): Task[List[StatusResponse]] = {
def setStatus(cm: CommitStatus): Task[StatusResponse] = {
val desc =
if (cm.isValid) "User signed CLA"
else "User needs to sign cla: https://www.lightbend.com/contribute/cla/scala"

val stat = cm match {
case Valid(user, commit) =>
Status("success", claUrl(user), desc)
case Invalid(user, commit) =>
Status("failure", claUrl(user), desc)
case MissingUser(commit) =>
Status("failure", "", "Missing valid github user for this PR")
case CLAServiceDown(user, commit) =>
Status("pending", claUrl(user), "CLA Service is down")
}

for {
endpoint <- toUri(statusUrl(cm.commit.sha))
req <- postRequest(endpoint).withBody(stat.asJson).pure[Task]
res <- httpClient.expect(req)(jsonOf[StatusResponse])
} yield res
}

Task.gatherUnordered(xs.map(setStatus))
}

private[this] val ExtractLink = """<([^>]+)>; rel="([^"]+)"""".r
def findNext(header: Option[Header]): Option[String] = header.flatMap { header =>
val value = header.value

value
.split(',')
.collect {
case ExtractLink(url, kind) if kind == "next" =>
url
}
.headOption
}

def getCommits(issueNbr: Int, httpClient: Client): Task[List[Commit]] = {
def makeRequest(url: String): Task[List[Commit]] =
for {
endpoint <- toUri(url)
req <- getRequest(endpoint).pure[Task]
res <- httpClient.fetch(req){ res =>
val link = CaseInsensitiveString("Link")
val next = findNext(res.headers.get(link)).map(makeRequest).getOrElse(Task.now(Nil))

res.as[List[Commit]](jsonOf[List[Commit]]).flatMap(commits => next.map(commits ++ _))
}
} yield res

makeRequest(commitsUrl(issueNbr))
}

def checkPullRequest(issue: Issue): Task[Response] = {
val httpClient = PooledHttp1Client()

for {
// First get all the commits from the PR
commits <- getCommits(issue.number, httpClient)

// Then check the CLA of each commit for both author and committer
statuses <- checkCLA(commits, httpClient)

// Send statuses to Github and exit
_ <- sendStatuses(statuses, httpClient)
_ <- shutdownClient(httpClient).pure[Task]
resp <- Ok("All statuses checked")
} yield resp
}
}
43 changes: 43 additions & 0 deletions bot/src/dotty/tools/bot/model/Github.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package dotty.tools.bot
package model

object Github {
case class PullRequest(
url: String,
id: Long,
commits_url: String
)

case class Issue(
number: Int,
pull_request: Option[PullRequest]
)

case class CommitInfo(
message: String
)

case class Commit(
sha: String,
author: Author,
committer: Author,
commit: CommitInfo
)

case class Author(
login: Option[String]
)

case class Status(
state: String,
target_url: String,
description: String,
context: String = "CLA"
)

case class StatusResponse(
url: String,
id: Long,
state: String
)
}
91 changes: 91 additions & 0 deletions bot/test/PRServiceTests.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package dotty.tools.bot

import org.junit.Assert._
import org.junit.Test

import io.circe._
import io.circe.generic.auto._
import io.circe.syntax._
import io.circe.parser.decode

import model.Github._
import org.http4s.client.blaze._
import scalaz.concurrent.Task

class PRServiceTests extends PullRequestService {
val user = sys.env("USER")
val token = sys.env("TOKEN")

def getResource(r: String): String =
Option(getClass.getResourceAsStream(r)).map(scala.io.Source.fromInputStream)
.map(_.mkString)
.getOrElse(throw new Exception(s"resource not found: $r"))

@Test def canUnmarshalIssueJson = {
val json = getResource("/test-pr.json")
val issue: Issue = decode[Issue](json) match {
case Right(is: Issue) => is
case Left(ex) => throw ex
}

assert(issue.pull_request.isDefined, "missing pull request")
}

@Test def canGetAllCommitsFromPR = {
val httpClient = PooledHttp1Client()
val issueNbr = 1941 // has 2 commits: https://github.com/lampepfl/dotty/pull/1941/commits

val List(c1, c2) = getCommits(issueNbr, httpClient).run

assertEquals(
"Represent untyped operators as Ident instead of Name",
c1.commit.message.takeWhile(_ != '\n')
)

assertEquals(
"Better positions for infix term operations.",
c2.commit.message.takeWhile(_ != '\n')
)
}

@Test def canGetMoreThan100Commits = {
val httpClient = PooledHttp1Client()
val issueNbr = 1840 // has >100 commits: https://github.com/lampepfl/dotty/pull/1840/commits

val numberOfCommits = getCommits(issueNbr, httpClient).run.length

assert(
numberOfCommits > 100,
s"PR 1840, should have a number of commits greater than 100, but was: $numberOfCommits"
)
}

@Test def canCheckCLA = {
val httpClient = PooledHttp1Client()
val validUserCommit = Commit("sha-here", Author(Some("felixmulder")), Author(Some("felixmulder")), CommitInfo(""))
val statuses: List[CommitStatus] = checkCLA(validUserCommit :: Nil, httpClient).run

assert(statuses.length == 1, s"wrong number of valid statuses: got ${statuses.length}, expected 1")
httpClient.shutdownNow()
}

@Test def canSetStatus = {
val httpClient = PooledHttp1Client()
val sha = "fa64b4b613fe5e78a5b4185b4aeda89e2f1446ff"
val status = Invalid("smarter", Commit(sha, Author(Some("smarter")), Author(Some("smarter")), CommitInfo("")))

val statuses: List[StatusResponse] = sendStatuses(status :: Nil, httpClient).run

assert(
statuses.length == 1,
s"assumed one status response would be returned, got: ${statuses.length}"
)

assert(
statuses.head.state == "failure",
s"status set had wrong state, expected 'failure', got: ${statuses.head.state}"
)

httpClient.shutdownNow()
}
}
Loading