Skip to content

Commit 13788d1

Browse files
committed
Implement relevant functionality for bot, pagination!
1 parent 9a640c0 commit 13788d1

File tree

4 files changed

+204
-43
lines changed

4 files changed

+204
-43
lines changed

bot/src/dotty/tools/bot/BotServer.scala

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,16 @@ import scalaz.concurrent.Task
77

88
object Main extends ServerApp with PullRequestService {
99

10+
val user = sys.env("USER")
11+
val token = sys.env("TOKEN")
12+
1013
/** Services mounted to the server */
1114
final val services = prService
1215

13-
override def server(args: List[String]): Task[Server] =
16+
override def server(args: List[String]): Task[Server] = {
1417
BlazeBuilder
1518
.bindHttp(8080, "localhost")
1619
.mountService(services, "/api")
1720
.start
21+
}
1822
}

bot/src/dotty/tools/bot/PullRequestService.scala

Lines changed: 113 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,115 +1,186 @@
11
package dotty.tools.bot
22

3-
import org.http4s._
3+
import org.http4s.{ Status => _, _ }
44
import org.http4s.client.blaze._
55
import org.http4s.client.Client
6+
import org.http4s.headers.Authorization
67

78
import scalaz.concurrent.Task
9+
import scala.util.control.NonFatal
810

911
import io.circe._
1012
import io.circe.generic.auto._
1113
import io.circe.syntax._
1214
import org.http4s.circe._
1315
import org.http4s.dsl._
16+
import org.http4s.util._
1417

15-
import github4s.Github
16-
import github4s.jvm.Implicits._
17-
import github4s.free.domain.{ Commit, Issue }
18+
import model.Github._
1819

1920
trait PullRequestService {
2021

22+
/** Username for authorized admin */
23+
def user: String
24+
25+
/** OAuth token needed for user to create statuses */
26+
def token: String
27+
28+
/** Pull Request HTTP service */
2129
val prService = HttpService {
2230
case request @ POST -> Root =>
2331
request.as(jsonOf[Issue]).flatMap(checkPullRequest)
2432
}
2533

26-
private case class CLASignature(
34+
private[this] lazy val authHeader = {
35+
val creds = BasicCredentials(user, token)
36+
new Authorization(creds)
37+
}
38+
39+
private final case class CLASignature(
2740
user: String,
2841
signed: Boolean,
2942
version: String,
3043
currentVersion: String
3144
)
3245

33-
private case class Status(
34-
state: String,
35-
target_url: String,
36-
description: String,
37-
context: String = "continuous-integration/CLA"
38-
)
39-
4046
def claUrl(userName: String): String =
4147
s"https://www.lightbend.com/contribute/cla/scala/check/$userName"
4248

4349
def commitsUrl(prNumber: Int): String =
44-
s"https://api.github.com/repos/lampepfl/dotty/pulls/$prNumber/commits"
50+
s"https://api.github.com/repos/lampepfl/dotty/pulls/$prNumber/commits?per_page=100"
51+
52+
def statusUrl(sha: String): String =
53+
s"https://api.github.com/repos/lampepfl/dotty/statuses/$sha"
4554

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

4958
def getRequest(endpoint: Uri): Task[Request] = Task.now {
50-
Request(uri = endpoint, method = Method.GET)
59+
Request(uri = endpoint, method = Method.GET).putHeaders(authHeader)
5160
}
5261

5362
def postRequest(endpoint: Uri): Task[Request] = Task.now {
54-
Request(uri = endpoint, method = Method.POST)
63+
Request(uri = endpoint, method = Method.POST).putHeaders(authHeader)
5564
}
5665

5766
def shutdownClient(client: Client): Task[Unit] = Task.now {
5867
client.shutdownNow()
5968
}
6069

6170
def users(xs: List[Commit]): Task[Set[String]] = Task.now {
62-
xs.map(_.login).flatten.toSet
71+
xs
72+
.flatMap { commit =>
73+
List(commit.author.login, commit.committer.login).flatten
74+
}
75+
.toSet
6376
}
6477

6578
sealed trait CommitStatus {
6679
def commit: Commit
6780
def isValid: Boolean
6881
}
69-
final case class Valid(commit: Commit) extends CommitStatus { def isValid = true }
70-
final case class Invalid(commit: Commit) extends CommitStatus { def isValid = false }
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 }
7186

7287
/** Partitions invalid and valid commits */
7388
def checkCLA(xs: List[Commit], httpClient: Client): Task[List[CommitStatus]] = {
74-
def checkUser(commit: Commit): Task[CommitStatus] = for {
75-
endpoint <- toUri(claUrl(commit.login.get))
76-
claReq <- getRequest(endpoint)
77-
claRes <- httpClient.expect(claReq)(jsonOf[CLASignature])
78-
res = if (claRes.signed) Valid(commit) else Invalid(commit)
79-
} yield res
80-
81-
Task.gatherUnordered(xs.filter(_.login.isDefined).map(checkUser))
89+
def checkUser(user: String, commit: Commit): Task[CommitStatus] = {
90+
val claStatus = for {
91+
endpoint <- toUri(claUrl(user))
92+
claReq <- getRequest(endpoint)
93+
claRes <- httpClient.expect(claReq)(jsonOf[CLASignature])
94+
res = if (claRes.signed) Valid(user, commit) else Invalid(user, commit)
95+
} yield res
96+
97+
claStatus.handleWith {
98+
case NonFatal(e) =>
99+
println(e)
100+
Task.now(CLAServiceDown(user, commit))
101+
}
102+
}
103+
104+
def checkCommit(commit: Commit, author: Author): Task[CommitStatus] =
105+
author.login.map(checkUser(_, commit)).getOrElse(Task.now(MissingUser(commit)))
106+
107+
Task.gatherUnordered {
108+
xs.flatMap {
109+
case c @ Commit(_, author, commiter, _) =>
110+
if (author == commiter) List(checkCommit(c, author))
111+
else List(
112+
checkCommit(c, author),
113+
checkCommit(c, commiter)
114+
)
115+
}
116+
}
82117
}
83118

84-
def sendStatuses(xs: List[CommitStatus], httpClient: Client): Task[Unit] = {
85-
def setStatus(cm: CommitStatus): Task[Unit] = for {
86-
endpoint <- toUri(cm.commit.url.replaceAll("git\\/commits", "statuses"))
87-
88-
target = claUrl(cm.commit.login.getOrElse("<invalid-user>"))
89-
state = if (cm.isValid) "success" else "failure"
90-
desc =
119+
def sendStatuses(xs: List[CommitStatus], httpClient: Client): Task[List[StatusResponse]] = {
120+
def setStatus(cm: CommitStatus): Task[StatusResponse] = {
121+
val desc =
91122
if (cm.isValid) "User signed CLA"
92123
else "User needs to sign cla: https://www.lightbend.com/contribute/cla/scala"
93124

94-
statusReq <- postRequest(endpoint).map(_.withBody(Status(state, target, desc).asJson))
95-
statusRes <- httpClient.expect(statusReq)(jsonOf[String])
96-
print <- Task.now(println(statusRes))
97-
} yield print
125+
val stat = cm match {
126+
case Valid(user, commit) =>
127+
Status("success", claUrl(user), desc)
128+
case Invalid(user, commit) =>
129+
Status("failure", claUrl(user), desc)
130+
case MissingUser(commit) =>
131+
Status("failure", "", "Missing valid github user for this PR")
132+
case CLAServiceDown(user, commit) =>
133+
Status("pending", claUrl(user), "CLA Service is down")
134+
}
135+
136+
for {
137+
endpoint <- toUri(statusUrl(cm.commit.sha))
138+
req <- postRequest(endpoint).map(_.withBody(stat.asJson))
139+
res <- httpClient.expect(req)(jsonOf[StatusResponse])
140+
} yield res
141+
}
142+
143+
Task.gatherUnordered(xs.map(setStatus))
144+
}
145+
146+
private[this] val ExtractLink = """<([^>]+)>; rel="([^"]+)"""".r
147+
def findNext(header: Option[Header]): Option[String] = header.flatMap { header =>
148+
val value = header.value
149+
150+
value
151+
.split(',')
152+
.collect {
153+
case ExtractLink(url, kind) if kind == "next" =>
154+
url
155+
}
156+
.headOption
157+
}
158+
159+
def getCommits(issueNbr: Int, httpClient: Client): Task[List[Commit]] = {
160+
def makeRequest(url: String): Task[List[Commit]] =
161+
for {
162+
endpoint <- toUri(url)
163+
req <- getRequest(endpoint)
164+
res <- httpClient.fetch(req){ res =>
165+
val link = CaseInsensitiveString("Link")
166+
val next = findNext(res.headers.get(link)).map(makeRequest).getOrElse(Task.now(Nil))
167+
168+
res.as[List[Commit]](jsonOf[List[Commit]]).flatMap(commits => next.map(commits ++ _))
169+
}
170+
} yield res
98171

99-
Task.gatherUnordered(xs.map(setStatus)).map(_ => ())
172+
makeRequest(commitsUrl(issueNbr))
100173
}
101174

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

105178
for {
106179
// First get all the commits from the PR
107-
endpoint <- toUri(commitsUrl(issue.number))
108-
commitsReq <- getRequest(endpoint)
109-
commitsRes <- httpClient.expect(commitsReq)(jsonOf[List[Commit]])
180+
commits <- getCommits(issue.number, httpClient)
110181

111182
// Then get check the CLA of each commit
112-
statuses <- checkCLA(commitsRes, httpClient)
183+
statuses <- checkCLA(commits, httpClient)
113184

114185
// Send statuses to Github and exit
115186
_ <- sendStatuses(statuses, httpClient)

bot/src/dotty/tools/bot/model/Github.scala

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,32 @@ object Github {
1212
number: Int,
1313
pull_request: Option[PullRequest]
1414
)
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+
)
1543
}

bot/test/PRServiceTests.scala

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,4 +31,62 @@ class PRServiceTests extends PullRequestService {
3131

3232
assert(issue.pull_request.isDefined, "missing pull request")
3333
}
34+
35+
@Test def canGetAllCommitsFromPR = {
36+
val httpClient = PooledHttp1Client()
37+
val issueNbr = 1941 // has 2 commits: https://github.com/lampepfl/dotty/pull/1941/commits
38+
39+
val List(c1, c2) = getCommits(issueNbr, httpClient).run
40+
41+
assertEquals(
42+
"Represent untyped operators as Ident instead of Name",
43+
c1.commit.message.takeWhile(_ != '\n')
44+
)
45+
46+
assertEquals(
47+
"Better positions for infix term operations.",
48+
c2.commit.message.takeWhile(_ != '\n')
49+
)
50+
}
51+
52+
@Test def canGetMoreThan100Commits = {
53+
val httpClient = PooledHttp1Client()
54+
val issueNbr = 1840 // has >100 commits: https://github.com/lampepfl/dotty/pull/1840/commits
55+
56+
val numberOfCommits = getCommits(issueNbr, httpClient).run.length
57+
58+
assert(
59+
numberOfCommits > 100,
60+
s"PR 1840, should have a number of commits greater than 100, but was: $numberOfCommits"
61+
)
62+
}
63+
64+
@Test def canCheckCLA = {
65+
val httpClient = PooledHttp1Client()
66+
val validUserCommit = Commit("sha-here", Author(Some("felixmulder")), Author(Some("felixmulder")), CommitInfo(""))
67+
val statuses: List[CommitStatus] = checkCLA(validUserCommit :: Nil, httpClient).run
68+
69+
assert(statuses.length == 1, s"wrong number of valid statuses: got ${statuses.length}, expected 1")
70+
httpClient.shutdownNow()
71+
}
72+
73+
@Test def canSetStatus = {
74+
val httpClient = PooledHttp1Client()
75+
val sha = "fa64b4b613fe5e78a5b4185b4aeda89e2f1446ff"
76+
val status = Invalid("smarter", Commit(sha, Author(Some("smarter")), Author(Some("smarter")), CommitInfo("")))
77+
78+
val statuses: List[StatusResponse] = sendStatuses(status :: Nil, httpClient).run
79+
80+
assert(
81+
statuses.length == 1,
82+
s"assumed one status response would be returned, got: ${statuses.length}"
83+
)
84+
85+
assert(
86+
statuses.head.state == "failure",
87+
s"status set had wrong state, expected 'failure', got: ${statuses.head.state}"
88+
)
89+
90+
httpClient.shutdownNow()
91+
}
3492
}

0 commit comments

Comments
 (0)