Skip to content

Commit aad721f

Browse files
committed
Add ability for bot to restart things when mentioned
1 parent 51f8bc1 commit aad721f

File tree

5 files changed

+572
-17
lines changed

5 files changed

+572
-17
lines changed

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

Lines changed: 124 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,29 @@ trait PullRequestService {
3737
/** Pull Request HTTP service */
3838
val prService = HttpService {
3939
case request @ POST -> Root =>
40-
request.as(jsonOf[Issue]).flatMap(checkPullRequest)
40+
val githubEvent =
41+
request.headers
42+
.get(CaseInsensitiveString("X-GitHub-Event"))
43+
.map(_.value).getOrElse("")
44+
45+
githubEvent match {
46+
case "pull_request" =>
47+
request.as(jsonOf[Issue]).flatMap(checkPullRequest)
48+
49+
case "issue_comment" =>
50+
request.as(jsonOf[IssueComment]).flatMap(respondToComment)
51+
52+
case "" =>
53+
BadRequest("Missing header: X-Github-Event")
54+
55+
case event =>
56+
BadRequest("Unsupported event: $event")
57+
58+
}
4159
}
4260

61+
private[this] val droneContext = "continuous-integration/drone/pr"
62+
4363
private final case class CLASignature(
4464
user: String,
4565
signed: Boolean,
@@ -148,7 +168,7 @@ trait PullRequestService {
148168
}
149169

150170
/** Ordered from earliest to latest */
151-
def getCommits(issueNbr: Int, httpClient: Client): Task[List[Commit]] = {
171+
def getCommits(issueNbr: Int)(implicit httpClient: Client): Task[List[Commit]] = {
152172
def makeRequest(url: String): Task[List[Commit]] =
153173
for {
154174
res <- httpClient.fetch(get(url)) { res =>
@@ -246,10 +266,10 @@ trait PullRequestService {
246266
}
247267

248268
def checkFreshPR(issue: Issue): Task[Response] = {
249-
val httpClient = PooledHttp1Client()
269+
implicit val httpClient = PooledHttp1Client()
250270

251271
for {
252-
commits <- getCommits(issue.number, httpClient)
272+
commits <- getCommits(issue.number)
253273
statuses <- checkCLA(commits, httpClient)
254274

255275
(validStatuses, invalidStatuses) = statuses.partition(_.isValid)
@@ -280,9 +300,31 @@ trait PullRequestService {
280300
private def extractCommitSha(status: StatusResponse): Task[String] =
281301
Task.delay(status.sha)
282302

303+
def startBuild(commit: Commit)(implicit client: Client): Task[Drone.Build] = {
304+
def pendingStatus(targetUrl: String): Status =
305+
Status("pending", targetUrl, "build restarted by bot", droneContext)
306+
307+
def filterStatuses(xs: List[StatusResponse]): Task[Int] =
308+
xs.filter { status =>
309+
(status.state == "failure" || status.state == "success") &&
310+
status.context == droneContext
311+
}
312+
.map(status => Task.now(status.target_url.split('/').last.toInt))
313+
.headOption
314+
.getOrElse(Task.fail(new NoSuchElementException("Couldn't find drone build for PR")))
315+
316+
for {
317+
statuses <- getStatus(commit, client)
318+
failed <- filterStatuses(statuses)
319+
build <- Drone.startBuild(failed, droneToken)
320+
setStatusReq <- post(statusUrl(commit.sha)).withAuth(githubUser, githubToken)
321+
newStatus = pendingStatus(s"http://dotty-ci.epfl.ch/lampepfl/dotty/$failed").asJson
322+
_ <- client.expect(setStatusReq.withBody(newStatus))(jsonOf[StatusResponse])
323+
} yield build
324+
}
325+
283326
def cancelBuilds(commits: List[Commit])(implicit client: Client): Task[Boolean] =
284327
Task.gatherUnordered {
285-
val droneContext = "continuous-integration/drone/pr"
286328
commits.map { commit =>
287329
for {
288330
statuses <- getStatus(commit, client)
@@ -295,10 +337,10 @@ trait PullRequestService {
295337
.map(xs => xs.foldLeft(true)(_ == _))
296338

297339
def checkSynchronize(issue: Issue): Task[Response] = {
298-
val httpClient = PooledHttp1Client()
340+
implicit val httpClient = PooledHttp1Client()
299341

300342
for {
301-
commits <- getCommits(issue.number, httpClient)
343+
commits <- getCommits(issue.number)
302344
statuses <- checkCLA(commits, httpClient)
303345
invalid = statuses.filterNot(_.isValid)
304346
_ <- sendStatuses(invalid, httpClient)
@@ -318,8 +360,80 @@ trait PullRequestService {
318360

319361
def checkPullRequest(issue: Issue): Task[Response] =
320362
issue.action match {
321-
case "opened" => checkFreshPR(issue)
322-
case "synchronize" => checkSynchronize(issue)
323-
case action => BadRequest(s"Unhandled action: $action")
363+
case Some("opened") => checkFreshPR(issue)
364+
case Some("synchronize") => checkSynchronize(issue)
365+
case Some(action) => BadRequest(s"Unhandled action: $action")
366+
case None => BadRequest("Cannot check pull request, missing action field")
367+
}
368+
369+
def restartCI(issue: Issue): Task[Response] = {
370+
implicit val client = PooledHttp1Client()
371+
372+
def restartedComment: Comment = {
373+
import scala.util.Random
374+
val answers = Array(
375+
"Okidokey, boss! :clap:",
376+
"You got it, homie! :pray:",
377+
"No problem, big shot! :punch:",
378+
"Sure thing, I got your back! :heart:",
379+
"No WAY! :-1: ...wait, don't fire me please! There, I did it! :tada:"
380+
)
381+
382+
Comment(Author(None), answers(Random.nextInt(answers.length)))
324383
}
384+
385+
for {
386+
commits <- getCommits(issue.number)
387+
latest = commits.last
388+
_ <- cancelBuilds(latest :: Nil)
389+
_ <- startBuild(latest)
390+
req <- post(issueCommentsUrl(issue.number)).withAuth(githubUser, githubToken)
391+
_ <- client.fetch(req.withBody(restartedComment.asJson))(Task.now)
392+
res <- Ok("Replied to request for CI restart")
393+
} yield res
394+
}
395+
396+
def cannotUnderstand(line: String, issueComment: IssueComment): Task[Response] = {
397+
implicit val client = PooledHttp1Client()
398+
val comment = Comment(Author(None), {
399+
s"""Hey, sorry - I could not understand what you meant by:
400+
|
401+
|> $line
402+
|
403+
|I'm just a dumb bot after all :cry:
404+
|
405+
|I mostly understand when your mention contains these words:
406+
|
407+
|- (re)check (the) cla
408+
|- recheck
409+
|- restart drone
410+
|
411+
|Maybe if you want to make me smarter, you could open a PR? :heart_eyes:
412+
|""".stripMargin
413+
})
414+
for {
415+
req <- post(issueCommentsUrl(issueComment.issue.number)).withAuth(githubUser, githubToken)
416+
_ <- client.fetch(req.withBody(comment.asJson))(Task.now)
417+
res <- Ok("Delivered could not understand comment")
418+
} yield res
419+
}
420+
421+
def extractMention(body: String): Option[String] =
422+
body.lines.find(_.startsWith("@dotty-bot:"))
423+
424+
/** TODO: The implementation here could be quite elegant if we used a trie instead */
425+
def interpretMention(line: String, issueComment: IssueComment): Task[Response] = {
426+
val loweredLine = line.toLowerCase
427+
if (loweredLine.contains("check cla") || loweredLine.contains("check the cla"))
428+
checkSynchronize(issueComment.issue)
429+
else if (loweredLine.contains("recheck") || loweredLine.contains("restart drone"))
430+
restartCI(issueComment.issue)
431+
else
432+
cannotUnderstand(line, issueComment)
433+
}
434+
435+
def respondToComment(issueComment: IssueComment): Task[Response] =
436+
extractMention(issueComment.comment.body)
437+
.map(interpretMention(_, issueComment))
438+
.getOrElse(Ok("Nothing to do here, move along!"))
325439
}

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

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,12 @@ package model
33

44
object Github {
55
case class Issue(
6-
action: String, // "opened", "reopened", "closed", "synchronize"
6+
action: Option[String], // "opened", "reopened", "closed", "synchronize"
77
number: Int,
88
pull_request: Option[PullRequest]
99
)
1010

11-
case class PullRequest(url: String, id: Long, commits_url: String)
11+
case class PullRequest(url: String, id: Option[Long], commits_url: Option[String])
1212

1313
case class CommitInfo(message: String)
1414

@@ -40,10 +40,8 @@ object Github {
4040
def sha: String = url.split('/').last
4141
}
4242

43-
case class Comment(user: Author)
44-
4543
/** A PR review */
46-
case class Review (body: String, event: String)
44+
case class Review(body: String, event: String)
4745

4846
object Review {
4947
def approve(body: String) = Review(body, "APPROVE")
@@ -52,4 +50,8 @@ object Github {
5250
}
5351

5452
case class ReviewResponse(body: String, state: String, id: Long)
53+
54+
case class IssueComment(action: String, issue: Issue, comment: Comment)
55+
56+
case class Comment(user: Author, body: String)
5557
}

bot/test/PRServiceTests.scala

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,22 @@ class PRServiceTests extends PullRequestService {
4141
assert(issue.pull_request.isDefined, "missing pull request")
4242
}
4343

44+
@Test def canUnmarshalIssueComment = {
45+
val json = getResource("/test-mention.json")
46+
val issueComment: IssueComment = decode[IssueComment](json) match {
47+
case Right(is: IssueComment) => is
48+
case Left(ex) => throw ex
49+
}
50+
51+
assert(
52+
issueComment.comment.body == "@dotty-bot: could you recheck this please?",
53+
s"incorrect body: ${issueComment.comment.body}"
54+
)
55+
}
56+
4457
@Test def canGetAllCommitsFromPR = {
4558
val issueNbr = 1941 // has 2 commits: https://github.com/lampepfl/dotty/pull/1941/commits
46-
val List(c1, c2) = withClient(getCommits(issueNbr, _))
59+
val List(c1, c2) = withClient(implicit client => getCommits(issueNbr))
4760

4861
assertEquals(
4962
"Represent untyped operators as Ident instead of Name",
@@ -58,7 +71,7 @@ class PRServiceTests extends PullRequestService {
5871

5972
@Test def canGetMoreThan100Commits = {
6073
val issueNbr = 1840 // has >100 commits: https://github.com/lampepfl/dotty/pull/1840/commits
61-
val numberOfCommits = withClient(getCommits(issueNbr, _)).length
74+
val numberOfCommits = withClient(implicit client => getCommits(issueNbr)).length
6275

6376
assert(
6477
numberOfCommits > 100,
@@ -124,4 +137,24 @@ class PRServiceTests extends PullRequestService {
124137
val killed = withClient(implicit client => Drone.stopBuild(1921, droneToken))
125138
assert(killed, "Couldn't kill build")
126139
}
140+
141+
@Test def canUnderstandWhenToRestartBuild = {
142+
val json = getResource("/test-mention.json")
143+
val issueComment: IssueComment = decode[IssueComment](json) match {
144+
case Right(is: IssueComment) => is
145+
case Left(ex) => throw ex
146+
}
147+
148+
assert(respondToComment(issueComment).run.status.code == 200)
149+
}
150+
151+
@Test def canTellUserWhenNotUnderstanding = {
152+
val json = getResource("/test-mention-no-understandy.json")
153+
val issueComment: IssueComment = decode[IssueComment](json) match {
154+
case Right(is: IssueComment) => is
155+
case Left(ex) => throw ex
156+
}
157+
158+
assert(respondToComment(issueComment).run.status.code == 200)
159+
}
127160
}

0 commit comments

Comments
 (0)