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 } }