diff options
author | Chris Rebert <code@rebertia.com> | 2014-07-02 11:49:28 +0400 |
---|---|---|
committer | Chris Rebert <code@rebertia.com> | 2014-07-02 11:49:28 +0400 |
commit | 8f9e91f089a2bcc42cb885051ab69f03907baab8 (patch) | |
tree | 02e1460844374e0d5b824bbdb0235367f665456b | |
parent | 86402bd72bc370545614294c08576ce5708d864e (diff) |
progress
10 files changed, 285 insertions, 5 deletions
diff --git a/src/main/scala/com/getbootstrap/rorschach/auditing/BaseAndHeadBranchesAuditor.scala b/src/main/scala/com/getbootstrap/rorschach/auditing/BaseAndHeadBranchesAuditor.scala new file mode 100644 index 0000000..0122490 --- /dev/null +++ b/src/main/scala/com/getbootstrap/rorschach/auditing/BaseAndHeadBranchesAuditor.scala @@ -0,0 +1,24 @@ +package com.getbootstrap.rorschach.auditing + +object BaseAndHeadBranchesAuditor { + def audit(baseBranch: String, headBranch: String): Seq[String] = { + Seq( + auditThatNotAgainstGhPages(baseBranch), + auditThatNotMergingGhPagesIntoMaster(baseBranch = baseBranch, headBranch = headBranch) + ).flatten + } + + def auditThatNotAgainstGhPages(baseBranch: String): Option[String] = { + baseBranch match { + case "gh-pages" => Some("Normal pull requests should never be against the gh-pages branch.") + case _ => None + } + } + + def auditThatNotMergingGhPagesIntoMaster(baseBranch: String, headBranch: String): Option[String] = { + (headBranch, baseBranch) match { + case ("gh-pages", "master") => Some("Normally, gh-pages should not be merged into master.") + case _ => None + } + } +} diff --git a/src/main/scala/com/getbootstrap/rorschach/auditing/ModifiedFilesAuditor.scala b/src/main/scala/com/getbootstrap/rorschach/auditing/ModifiedFilesAuditor.scala new file mode 100644 index 0000000..03dc9fd --- /dev/null +++ b/src/main/scala/com/getbootstrap/rorschach/auditing/ModifiedFilesAuditor.scala @@ -0,0 +1,54 @@ +package com.getbootstrap.rorschach.auditing + +object ModifiedFilesAuditor { + private implicit class PathString(filepath: String) { + def isDist: Boolean = filepath.startsWith("dist/") + def isNonMinifiedCss: Boolean = filepath.endsWith(".css") && !filepath.endsWith(".min.css") + def isSourceLess: Boolean = filepath.startsWith("less/") && filepath.endsWith(".less") + def isNonMinifiedJs: Boolean = filepath.endsWith(".js") && !filepath.endsWith(".min.js") + def isDistCss: Boolean = filepath.isDist && filepath.isNonMinifiedCss + def isDistJs: Boolean = filepath.isDist && filepath.isNonMinifiedJs + def isSourceJs: Boolean = filepath.startsWith("js/") && filepath.isNonMinifiedJs + } + + def audit(filepaths: Set[String]): Seq[String] = { + Seq(auditCname(filepaths), auditCss(filepaths), auditJs(filepaths)).flatten + } + + /** + * If dist/bootstrap.css etc. is modified, then less/<*>.less must also have been modified. + */ + private def auditCss(filepaths: Set[String]): Option[String] = { + val cssModified = filepaths.exists{ _.isDistCss } + val lessModified = filepaths.exists{ _.isSourceLess } + if (cssModified && !lessModified) { + Some("Changes must be made to the original Less source code, not just the compiled CSS.") + } + else { + None + } + } + + /** + * If dist/js/bootstrap.js etc. is modified, then js/<*>.js must also have been modified. + */ + private def auditJs(filepaths: Set[String]): Option[String] = { + val distJsModified = filepaths.exists{ _.isDistJs } + val sourceJsModified = filepaths.exists{ _.isSourceJs } + if (distJsModified && !sourceJsModified) { + Some("Changes must be made to the original JS source code, not just the generated concatenated JS.") + } + else { + None + } + } + + private def auditCname(filepaths: Set[String]): Option[String] = { + if (filepaths.contains("CNAME")) { + Some("The CNAME file should never be modified (except in extremely unlikely circumstances).") + } + else { + None + } + } +} diff --git a/src/main/scala/com/getbootstrap/rorschach/github/CommitFileStatus.scala b/src/main/scala/com/getbootstrap/rorschach/github/CommitFileStatus.scala new file mode 100644 index 0000000..d108a2d --- /dev/null +++ b/src/main/scala/com/getbootstrap/rorschach/github/CommitFileStatus.scala @@ -0,0 +1,28 @@ +package com.getbootstrap.rorschach.github + +sealed trait CommitFileStatus { + val StatusString: String +} +object Added extends CommitFileStatus { + override val StatusString = "added" +} +object Modified extends CommitFileStatus { + override val StatusString = "modified" +} +object Deleted extends CommitFileStatus { + override val StatusString = "deleted" +} +object Unknown extends CommitFileStatus { + override val StatusString = "UNKNOWN" +} + +object CommitFileStatus { + def apply(status: String): CommitFileStatus = { + status match { + case Added.StatusString => Added + case Modified.StatusString => Modified + case Deleted.StatusString => Deleted + case _ => Unknown + } + } +} diff --git a/src/main/scala/com/getbootstrap/rorschach/github/CommitSha.scala b/src/main/scala/com/getbootstrap/rorschach/github/CommitSha.scala new file mode 100644 index 0000000..d55002f --- /dev/null +++ b/src/main/scala/com/getbootstrap/rorschach/github/CommitSha.scala @@ -0,0 +1,5 @@ +package com.getbootstrap.rorschach.github + +class CommitSha(val sha: String) extends AnyVal { + override def toString = s"CommitSha(${sha})" +} diff --git a/src/main/scala/com/getbootstrap/rorschach/github/GitHubActorWithLogging.scala b/src/main/scala/com/getbootstrap/rorschach/github/GitHubActorWithLogging.scala new file mode 100644 index 0000000..d2ab6f6 --- /dev/null +++ b/src/main/scala/com/getbootstrap/rorschach/github/GitHubActorWithLogging.scala @@ -0,0 +1,12 @@ +package com.getbootstrap.rorschach.github + +import org.eclipse.egit.github.core.RepositoryId +import org.eclipse.egit.github.core.client.GitHubClient +import com.getbootstrap.rorschach.server.{Settings, ActorWithLogging} + +abstract class GitHubActorWithLogging extends ActorWithLogging { + private val settings = Settings(context.system) + protected val BootstrapRepoId = new RepositoryId("twbs", "bootstrap") + protected val gitHubClient = new GitHubClient() + gitHubClient.setCredentials(settings.BotUsername, settings.BotPassword) +} diff --git a/src/main/scala/com/getbootstrap/rorschach/github/GitHubIssueCommenter.scala b/src/main/scala/com/getbootstrap/rorschach/github/GitHubIssueCommenter.scala new file mode 100644 index 0000000..7e99154 --- /dev/null +++ b/src/main/scala/com/getbootstrap/rorschach/github/GitHubIssueCommenter.scala @@ -0,0 +1,40 @@ +package com.getbootstrap.rorschach.github + +import scala.util.{Try,Failure,Success} +import org.eclipse.egit.github.core.client.GitHubClient +import org.eclipse.egit.github.core.service.IssueService +import org.eclipse.egit.github.core.RepositoryId +import com.getbootstrap.rorschach.server.{ActorWithLogging, Settings} + + +class GitHubIssueCommenter extends GitHubActorWithLogging { + // val settings = Settings(context.system) + + private def tryToCommentOn(repo: RepositoryId, issue: IssueNumber, commentMarkdown: String) = { + val issueService = new IssueService(gitHubClient) + Try { issueService.createComment(repo, issue.number, commentMarkdown) } + } + + override def receive = { + case PullRequestFeedback(prNum, requester, messages) => { + val username = requester.getLogin + val commentMarkdown = s""" + |Hi @${username}! + | + |Thanks for wanting to contribute to Bootstrap by sending this pull request. + |Unfortunately, your pull request seems to have some problems: + |${messagesMarkdown} + | + |You'll need to **fix these mistakes** and revise your pull request before we can proceed further. + |Thanks! + | + |(*Please note that this is a fully automated comment.*) + """.stripMargin + + tryToCommentOn(BootstrapRepoId, prNum, commentMarkdown) match { + case Success(comment) => log.info(s"Successfully posted comment ${comment.getUrl} for ${prNum}") + case Failure(exc) => log.error(exc, s"Error posting comment for ${prNum}") + } + } + } +} diff --git a/src/main/scala/com/getbootstrap/rorschach/github/PullRequestFeedback.scala b/src/main/scala/com/getbootstrap/rorschach/github/PullRequestFeedback.scala new file mode 100644 index 0000000..a5dc57e --- /dev/null +++ b/src/main/scala/com/getbootstrap/rorschach/github/PullRequestFeedback.scala @@ -0,0 +1,9 @@ +package com.getbootstrap.rorschach.github + +import org.eclipse.egit.github.core.User + +case class PullRequestFeedback( + prNum: IssueNumber, + requester: User, + messages: Seq[String] +) diff --git a/src/main/scala/com/getbootstrap/rorschach/server/Boot.scala b/src/main/scala/com/getbootstrap/rorschach/server/Boot.scala index 5c77670..36923f8 100644 --- a/src/main/scala/com/getbootstrap/rorschach/server/Boot.scala +++ b/src/main/scala/com/getbootstrap/rorschach/server/Boot.scala @@ -31,11 +31,9 @@ object Boot extends App { implicit val system = ActorSystem("on-spray-can") // import actorSystem.dispatcher - val commenter = system.actorOf(Props(classOf[GitHubIssueCommenter])) - val localValidator = system.actorOf(Props(classOf[ValidatorSingletonActor], commenter), "validator-service") - val exampleFetcherPool = system.actorOf(SmallestMailboxPool(5).props(Props(classOf[LiveExampleFetcher], localValidator)), "example-fetcher-pool") - val issueCommentEventHandler = system.actorOf(Props(classOf[IssueCommentEventHandler], exampleFetcherPool), "issue-comment-event-handler") - val webService = system.actorOf(Props(classOf[LmvtfyActor], issueCommentEventHandler), "lmvtfy-service") + val commenter = system.actorOf(SmallestMailboxPool(3).props(Props(classOf[GitHubIssueCommenter])), "gh-pr-commenter") + val prAuditorPool = system.actorOf(SmallestMailboxPool(5).props(Props(classOf[PullRequestEventHandler], commenter)), "pr-auditor-pool") + val webService = system.actorOf(Props(classOf[RorschachActor], prAuditorPool), "rorschach-service") implicit val timeout = Timeout(15.seconds) IO(Http) ? Http.Bind(webService, interface = "0.0.0.0", port = port) diff --git a/src/main/scala/com/getbootstrap/rorschach/server/PullRequestEventHandler.scala b/src/main/scala/com/getbootstrap/rorschach/server/PullRequestEventHandler.scala new file mode 100644 index 0000000..0b908bb --- /dev/null +++ b/src/main/scala/com/getbootstrap/rorschach/server/PullRequestEventHandler.scala @@ -0,0 +1,61 @@ +package com.getbootstrap.rorschach.server + +import com.getbootstrap.rorschach.auditing.{BaseAndHeadBranchesAuditor, ModifiedFilesAuditor} + +import scala.collection.JavaConverters._ +import scala.util.{Try,Success,Failure} +import akka.actor.ActorRef +import com.getbootstrap.rorschach.github._ +import org.eclipse.egit.github.core.service.CommitService +import org.eclipse.egit.github.core._ + +class PullRequestEventHandler(commenter: ActorRef) extends GitHubActorWithLogging { + implicit class RichRepository(repo: Repository) { + def repositoryId: RepositoryId = new RepositoryId(repo.getOwner.getLogin, repo.getName) + } + implicit class RichPullRequestMarker(marker: PullRequestMarker) { + def commitSha: CommitSha = new CommitSha(marker.getSha) + } + implicit class RichCommitFile(file: CommitFile) { + def status: CommitFileStatus = CommitFileStatus(file.getStatus) + } + + private def modifiedFilesFor(repoId: RepositoryId, base: CommitSha, head: CommitSha) = { + val commitService = new CommitService(gitHubClient) + Try { commitService.compare(repoId, base.sha, head.sha) }.map { comparison => + val affectedFiles = comparison.getFiles.asScala + affectedFiles.filter{ _.status == Modified }.map{ _.getFilename }.toSet[String] + } + } + + override def receive = { + case pr: PullRequest => { + val bsBase = pr.getBase + val prHead = pr.getHead + bsBase.getRepo.repositoryId match { + case BootstrapRepoId => { + val base = bsBase.commitSha + val head = prHead.commitSha + val foreignRepoId = prHead.getRepo.repositoryId + + val fileMessages = modifiedFilesFor(foreignRepoId, base, head) match { + case Failure(exc) => { + log.error(exc, s"Could not get modified files for commits ${base}...${head} for ${foreignRepoId}") + Nil + } + case Success(modifiedFiles) => { + ModifiedFilesAuditor.audit(modifiedFiles) + } + } + val branchMessages = BaseAndHeadBranchesAuditor.audit(baseBranch = bsBase.getRef, headBranch = prHead.getRef) + + val allMessages = fileMessages ++ branchMessages + if (allMessages.nonEmpty) { + commenter ! PullRequestFeedback(pr.getNumber, pr.getUser, allMessages) + } + } + case otherRepo => log.error(s"Received event from GitHub about irrelevant repository: ${otherRepo}") + } + } + } +} diff --git a/src/main/scala/com/getbootstrap/rorschach/server/RorschachActor.scala b/src/main/scala/com/getbootstrap/rorschach/server/RorschachActor.scala new file mode 100644 index 0000000..7dfd09e --- /dev/null +++ b/src/main/scala/com/getbootstrap/rorschach/server/RorschachActor.scala @@ -0,0 +1,49 @@ +package com.getbootstrap.rorschach.server + +import akka.actor.ActorRef +import spray.routing._ +import spray.http._ + + +class RorschachActor(protected val pullRequestEventHandler: ActorRef) extends ActorWithLogging with HttpService { + import GitHubPullRequestWebHooksDirectives.authenticatedPullRequestEvent + + val settings = Settings(context.system) + override def actorRefFactory = context + override def receive = runRoute(theOnlyRoute) + + val theOnlyRoute = + pathPrefix("rorschach") { pathEndOrSingleSlash { + get { + complete(StatusCodes.OK, "Hi! Rorschach is online.") + } ~ + post { + headerValueByName("X-Github-Event") { githubEvent => + githubEvent match { + case "ping" => { + log.info("Successfully received GitHub webhook ping.") + complete(StatusCodes.OK) + } + case "pull_request" => { + authenticatedPullRequestEvent(settings.WebHookSecretKey.toArray) { event => + event.getAction match { + case "opened" | "synchronize" | "reopened" => { + var pr = event.getPullRequest + if (pr.getState == "open") { + pullRequestEventHandler ! pr + complete(StatusCodes.OK) + } + else { + complete(StatusCodes.OK, s"Ignoring event about closed pull request #${pr.getId}") + } + } + case _ => complete(StatusCodes.OK, "Ignoring irrelevant action") + } + } + } + case _ => complete(StatusCodes.BadRequest, "Unexpected event type") + } + } + } + }} +} |