Skip to content

Commit e8513e1

Browse files
authored
Merge pull request #1963 from dotty-staging/topic/dotty-bot
add dotty-bot
2 parents 8bdc91f + b760a50 commit e8513e1

File tree

7 files changed

+811
-0
lines changed

7 files changed

+811
-0
lines changed

bot/src/dotty/tools/bot/Main.scala

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package dotty.tools.bot
2+
3+
import org.http4s.server.{ Server, ServerApp }
4+
import org.http4s.server.blaze._
5+
6+
import scalaz.concurrent.Task
7+
8+
object Main extends ServerApp with PullRequestService {
9+
10+
val user = sys.env("USER")
11+
val token = sys.env("TOKEN")
12+
val port = sys.env("PORT").toInt
13+
14+
/** Services mounted to the server */
15+
final val services = prService
16+
17+
override def server(args: List[String]): Task[Server] = {
18+
BlazeBuilder
19+
.bindHttp(port, "0.0.0.0")
20+
.mountService(services, "/api")
21+
.start
22+
}
23+
}
Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
package dotty.tools.bot
2+
3+
import org.http4s.{ Status => _, _ }
4+
import org.http4s.client.blaze._
5+
import org.http4s.client.Client
6+
import org.http4s.headers.Authorization
7+
8+
import cats.syntax.applicative._
9+
import scalaz.concurrent.Task
10+
import scala.util.control.NonFatal
11+
import scala.Function.tupled
12+
13+
import io.circe._
14+
import io.circe.generic.auto._
15+
import io.circe.syntax._
16+
import org.http4s.circe._
17+
import org.http4s.dsl._
18+
import org.http4s.util._
19+
20+
import model.Github._
21+
22+
object TaskIsApplicative {
23+
implicit val taskIsApplicative = new cats.Applicative[Task] {
24+
def pure[A](x: A): Task[A] = Task.now(x)
25+
def ap[A, B](ff: Task[A => B])(fa: Task[A]): Task[B] =
26+
for(f <- ff; a <- fa) yield f(a)
27+
}
28+
}
29+
import TaskIsApplicative._
30+
31+
trait PullRequestService {
32+
33+
/** Username for authorized admin */
34+
def user: String
35+
36+
/** OAuth token needed for user to create statuses */
37+
def token: String
38+
39+
/** Pull Request HTTP service */
40+
val prService = HttpService {
41+
case request @ POST -> Root =>
42+
request.as(jsonOf[Issue]).flatMap(checkPullRequest)
43+
}
44+
45+
private[this] lazy val authHeader = {
46+
val creds = BasicCredentials(user, token)
47+
new Authorization(creds)
48+
}
49+
50+
private final case class CLASignature(
51+
user: String,
52+
signed: Boolean,
53+
version: String,
54+
currentVersion: String
55+
)
56+
57+
def claUrl(userName: String): String =
58+
s"https://www.lightbend.com/contribute/cla/scala/check/$userName"
59+
60+
def commitsUrl(prNumber: Int): String =
61+
s"https://api.github.com/repos/lampepfl/dotty/pulls/$prNumber/commits?per_page=100"
62+
63+
def statusUrl(sha: String): String =
64+
s"https://api.github.com/repos/lampepfl/dotty/statuses/$sha"
65+
66+
def toUri(url: String): Task[Uri] =
67+
Uri.fromString(url).fold(Task.fail, Task.now)
68+
69+
def getRequest(endpoint: Uri): Request =
70+
Request(uri = endpoint, method = Method.GET).putHeaders(authHeader)
71+
72+
def postRequest(endpoint: Uri): Request =
73+
Request(uri = endpoint, method = Method.POST).putHeaders(authHeader)
74+
75+
def shutdownClient(client: Client): Unit =
76+
client.shutdownNow()
77+
78+
sealed trait CommitStatus {
79+
def commit: Commit
80+
def isValid: Boolean
81+
}
82+
final case class Valid(user: String, commit: Commit) extends CommitStatus { def isValid = true }
83+
final case class Invalid(user: String, commit: Commit) extends CommitStatus { def isValid = false }
84+
final case class CLAServiceDown(user: String, commit: Commit) extends CommitStatus { def isValid = false }
85+
final case class MissingUser(commit: Commit) extends CommitStatus { def isValid = false }
86+
87+
/** Partitions invalid and valid commits */
88+
def checkCLA(xs: List[Commit], httpClient: Client): Task[List[CommitStatus]] = {
89+
def checkUser(user: String): Task[Commit => CommitStatus] = {
90+
val claStatus = for {
91+
endpoint <- toUri(claUrl(user))
92+
claReq <- getRequest(endpoint).pure[Task]
93+
claRes <- httpClient.expect(claReq)(jsonOf[CLASignature])
94+
} yield { (commit: Commit) =>
95+
if (claRes.signed) Valid(user, commit)
96+
else Invalid(user, commit)
97+
}
98+
99+
claStatus.handleWith {
100+
case NonFatal(e) =>
101+
println(e)
102+
Task.now((commit: Commit) => CLAServiceDown(user, commit))
103+
}
104+
}
105+
106+
def checkCommit(author: Author, commit: List[Commit]): Task[List[CommitStatus]] =
107+
author.login.map(checkUser)
108+
.getOrElse(Task.now(MissingUser))
109+
.map(f => commit.map(f))
110+
111+
Task.gatherUnordered {
112+
val groupedByAuthor: Map[Author, List[Commit]] = xs.groupBy(_.author)
113+
groupedByAuthor.map(tupled(checkCommit)).toList
114+
}.map(_.flatten)
115+
}
116+
117+
def sendStatuses(xs: List[CommitStatus], httpClient: Client): Task[List[StatusResponse]] = {
118+
def setStatus(cm: CommitStatus): Task[StatusResponse] = {
119+
val desc =
120+
if (cm.isValid) "User signed CLA"
121+
else "User needs to sign cla: https://www.lightbend.com/contribute/cla/scala"
122+
123+
val stat = cm match {
124+
case Valid(user, commit) =>
125+
Status("success", claUrl(user), desc)
126+
case Invalid(user, commit) =>
127+
Status("failure", claUrl(user), desc)
128+
case MissingUser(commit) =>
129+
Status("failure", "", "Missing valid github user for this PR")
130+
case CLAServiceDown(user, commit) =>
131+
Status("pending", claUrl(user), "CLA Service is down")
132+
}
133+
134+
for {
135+
endpoint <- toUri(statusUrl(cm.commit.sha))
136+
req <- postRequest(endpoint).withBody(stat.asJson).pure[Task]
137+
res <- httpClient.expect(req)(jsonOf[StatusResponse])
138+
} yield res
139+
}
140+
141+
Task.gatherUnordered(xs.map(setStatus))
142+
}
143+
144+
private[this] val ExtractLink = """<([^>]+)>; rel="([^"]+)"""".r
145+
def findNext(header: Option[Header]): Option[String] = header.flatMap { header =>
146+
val value = header.value
147+
148+
value
149+
.split(',')
150+
.collect {
151+
case ExtractLink(url, kind) if kind == "next" =>
152+
url
153+
}
154+
.headOption
155+
}
156+
157+
def getCommits(issueNbr: Int, httpClient: Client): Task[List[Commit]] = {
158+
def makeRequest(url: String): Task[List[Commit]] =
159+
for {
160+
endpoint <- toUri(url)
161+
req <- getRequest(endpoint).pure[Task]
162+
res <- httpClient.fetch(req){ res =>
163+
val link = CaseInsensitiveString("Link")
164+
val next = findNext(res.headers.get(link)).map(makeRequest).getOrElse(Task.now(Nil))
165+
166+
res.as[List[Commit]](jsonOf[List[Commit]]).flatMap(commits => next.map(commits ++ _))
167+
}
168+
} yield res
169+
170+
makeRequest(commitsUrl(issueNbr))
171+
}
172+
173+
def checkPullRequest(issue: Issue): Task[Response] = {
174+
val httpClient = PooledHttp1Client()
175+
176+
for {
177+
// First get all the commits from the PR
178+
commits <- getCommits(issue.number, httpClient)
179+
180+
// Then check the CLA of each commit for both author and committer
181+
statuses <- checkCLA(commits, httpClient)
182+
183+
// Send statuses to Github and exit
184+
_ <- sendStatuses(statuses, httpClient)
185+
_ <- shutdownClient(httpClient).pure[Task]
186+
resp <- Ok("All statuses checked")
187+
} yield resp
188+
}
189+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package dotty.tools.bot
2+
package model
3+
4+
object Github {
5+
case class PullRequest(
6+
url: String,
7+
id: Long,
8+
commits_url: String
9+
)
10+
11+
case class Issue(
12+
number: Int,
13+
pull_request: Option[PullRequest]
14+
)
15+
16+
case class CommitInfo(
17+
message: String
18+
)
19+
20+
case class Commit(
21+
sha: String,
22+
author: Author,
23+
committer: Author,
24+
commit: CommitInfo
25+
)
26+
27+
case class Author(
28+
login: Option[String]
29+
)
30+
31+
case class Status(
32+
state: String,
33+
target_url: String,
34+
description: String,
35+
context: String = "CLA"
36+
)
37+
38+
case class StatusResponse(
39+
url: String,
40+
id: Long,
41+
state: String
42+
)
43+
}

bot/test/PRServiceTests.scala

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
package dotty.tools.bot
2+
3+
import org.junit.Assert._
4+
import org.junit.Test
5+
6+
import io.circe._
7+
import io.circe.generic.auto._
8+
import io.circe.syntax._
9+
import io.circe.parser.decode
10+
11+
import model.Github._
12+
import org.http4s.client.blaze._
13+
import scalaz.concurrent.Task
14+
15+
class PRServiceTests extends PullRequestService {
16+
val user = sys.env("USER")
17+
val token = sys.env("TOKEN")
18+
19+
def getResource(r: String): String =
20+
Option(getClass.getResourceAsStream(r)).map(scala.io.Source.fromInputStream)
21+
.map(_.mkString)
22+
.getOrElse(throw new Exception(s"resource not found: $r"))
23+
24+
@Test def canUnmarshalIssueJson = {
25+
val json = getResource("/test-pr.json")
26+
val issue: Issue = decode[Issue](json) match {
27+
case Right(is: Issue) => is
28+
case Left(ex) => throw ex
29+
}
30+
31+
assert(issue.pull_request.isDefined, "missing pull request")
32+
}
33+
34+
@Test def canGetAllCommitsFromPR = {
35+
val httpClient = PooledHttp1Client()
36+
val issueNbr = 1941 // has 2 commits: https://github.com/lampepfl/dotty/pull/1941/commits
37+
38+
val List(c1, c2) = getCommits(issueNbr, httpClient).run
39+
40+
assertEquals(
41+
"Represent untyped operators as Ident instead of Name",
42+
c1.commit.message.takeWhile(_ != '\n')
43+
)
44+
45+
assertEquals(
46+
"Better positions for infix term operations.",
47+
c2.commit.message.takeWhile(_ != '\n')
48+
)
49+
}
50+
51+
@Test def canGetMoreThan100Commits = {
52+
val httpClient = PooledHttp1Client()
53+
val issueNbr = 1840 // has >100 commits: https://github.com/lampepfl/dotty/pull/1840/commits
54+
55+
val numberOfCommits = getCommits(issueNbr, httpClient).run.length
56+
57+
assert(
58+
numberOfCommits > 100,
59+
s"PR 1840, should have a number of commits greater than 100, but was: $numberOfCommits"
60+
)
61+
}
62+
63+
@Test def canCheckCLA = {
64+
val httpClient = PooledHttp1Client()
65+
val validUserCommit = Commit("sha-here", Author(Some("felixmulder")), Author(Some("felixmulder")), CommitInfo(""))
66+
val statuses: List[CommitStatus] = checkCLA(validUserCommit :: Nil, httpClient).run
67+
68+
assert(statuses.length == 1, s"wrong number of valid statuses: got ${statuses.length}, expected 1")
69+
httpClient.shutdownNow()
70+
}
71+
72+
@Test def canSetStatus = {
73+
val httpClient = PooledHttp1Client()
74+
val sha = "fa64b4b613fe5e78a5b4185b4aeda89e2f1446ff"
75+
val status = Invalid("smarter", Commit(sha, Author(Some("smarter")), Author(Some("smarter")), CommitInfo("")))
76+
77+
val statuses: List[StatusResponse] = sendStatuses(status :: Nil, httpClient).run
78+
79+
assert(
80+
statuses.length == 1,
81+
s"assumed one status response would be returned, got: ${statuses.length}"
82+
)
83+
84+
assert(
85+
statuses.head.state == "failure",
86+
s"status set had wrong state, expected 'failure', got: ${statuses.head.state}"
87+
)
88+
89+
httpClient.shutdownNow()
90+
}
91+
}

0 commit comments

Comments
 (0)