aboutsummaryrefslogblamecommitdiff
path: root/bot/src/dotty/tools/bot/PullRequestService.scala
blob: c0745acc8ca2cc9a8d627b24e10bdaaeb032e033 (plain) (tree)
1
2
3
4
5
6
7
8
9

                       
                                    

                                
                                       
 
                                
                             
                                  





                              
                        
 
                     
 








                                                               

                          






                                                       




                                                         





                                             





                          



                                                                    



                                                                                       



                                                 
                                          
                                                                       
 
                                           
                                                                        
 
                                            
                        
 



                             



                                                                                                            


                                                                                  


                                                                       
                                                   























                                                                                     

   


                                                                                              


                                                                                     












                                                                        
                                                                          























                                                                                   
                                              






                                                                                               
 
                                     






                                                        
                                                     
 

                                                                        


                                                      
                                                         



                                              
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 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, commit: Commit): Task[CommitStatus] = {
      val claStatus = for {
        endpoint <- toUri(claUrl(user))
        claReq   <- getRequest(endpoint).pure[Task]
        claRes   <- httpClient.expect(claReq)(jsonOf[CLASignature])
        res = if (claRes.signed) Valid(user, commit) else Invalid(user, commit)
      } yield res

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

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

    Task.gatherUnordered {
      xs.flatMap {
        case c @ Commit(_, author, commiter, _) =>
          if (author == commiter) List(checkCommit(c, author))
          else List(
            checkCommit(c, author),
            checkCommit(c, commiter)
          )
      }
    }
  }

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