aboutsummaryrefslogblamecommitdiff
path: root/bot/src/dotty/tools/bot/PullRequestService.scala
blob: 5ae0b37d0411b58cdad5662142f9945f9e8d0067 (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 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
  }
}