Skip to content

Commit

Permalink
Skeleton project to build a review report from Phabricator endpoints
Browse files Browse the repository at this point in the history
  • Loading branch information
Roshan Sumbaly committed Jun 29, 2015
1 parent 2c8a379 commit 51db60e
Show file tree
Hide file tree
Showing 19 changed files with 816 additions and 0 deletions.
14 changes: 14 additions & 0 deletions .gitignore
@@ -0,0 +1,14 @@
logs
project/project
project/target
target
.history
dist
/.idea
/*.iml
/out
/.idea_modules
/.classpath
/.project
/.settings
.DS_Store
10 changes: 10 additions & 0 deletions README.md
@@ -0,0 +1,10 @@
# Phabricator reports

Simple web application using Play and Guice to gather statistics from phabricator.

Given a list of phabricator user names, generates a report of where the reviews are landing.

## To run
> sbt
> run
> In browser - http://localhost:9000/phab?usernames=rsumbaly,blah&nWeeks=4
26 changes: 26 additions & 0 deletions app/Global.scala
@@ -0,0 +1,26 @@
import com.google.inject.Guice
import com.typesafe.scalalogging.slf4j.StrictLogging
import play.api.GlobalSettings
import play.utils.Colors
import play.api.Play.current

object Global extends GlobalSettings with StrictLogging {

/**
* Bind types such that whenever TextGenerator is required, an instance of WelcomeTextGenerator will be used.
*/
lazy val injector = Guice.createInjector(new PhabricatorMetricsModule(current))

/**
* Controllers must be resolved through the application context. There is a special method of GlobalSettings
* that we can override to resolve a given controller. This resolution is required by the Play router.
*/
override def getControllerInstance[A](controllerClass: Class[A]): A = injector.getInstance(controllerClass)

override def onStart(application: play.api.Application) {
logger.info(Colors.green("Starting phabricator metrics"))
}

override def onStop(application: play.api.Application) { }

}
38 changes: 38 additions & 0 deletions app/PhabricatorMetricsModule.scala
@@ -0,0 +1,38 @@
import javax.inject.Singleton

import com.google.inject.Provides
import phabricator._
import com.tzavellas.sse.guice.ScalaModule
import play.api.Configuration
import play.api.Application
import reporter.Reporter
import reporter.ReporterImpl
import scala.concurrent.ExecutionContext

class PhabricatorMetricsModule(app: Application) extends ScalaModule {

protected def configure() {
bind[PhabricatorClient].to[PhabricatorClientImpl]
bind[PhabricatorQuery].to[PhabricatorQueryImpl]
bind[Reporter].to[ReporterImpl]
}

@Provides
def playExecutionContext(): ExecutionContext =
play.api.libs.concurrent.Execution.defaultContext

@Provides
@Singleton
def currentConfiguration(): Configuration = app.configuration

@Provides
@Singleton
private[this] def phabricatorConfig(configuration: Configuration): PhabricatorConfig = {
val configs = configuration.getConfig("phabricator").get
PhabricatorConfig(
apiUrl = configs.getString("apiUrl").get,
user = configs.getString("user").get,
certificate = configs.getString("certificate").get)
}

}
35 changes: 35 additions & 0 deletions app/controllers/Application.scala
@@ -0,0 +1,35 @@
package controllers

import javax.inject.Inject

import play.api.mvc._

import javax.inject.Singleton

import reporter.Reporter

import scala.concurrent.Future

import scala.concurrent.ExecutionContext

@Singleton
class Application @Inject() (
reporter: Reporter)
(implicit ec: ExecutionContext) extends Controller {

def main(usernames: String, nWeeks: Int) = Action {
Async {
val teamUsernames = usernames.trim.split(",").toList
if (teamUsernames.length <= 1) {
Future.successful(BadRequest("Should have atleast two team members"))
} else {
reporter.generateReport(teamUsernames, nWeeks).map { report =>
Ok(views.html.main(report, nWeeks))
}.recover { case e: Throwable =>
BadRequest(s"Error while retrieving report - ${e.getMessage}")
}
}
}
}

}
97 changes: 97 additions & 0 deletions app/phabricator/PhabricatorClient.scala
@@ -0,0 +1,97 @@
package phabricator

import javax.inject.Inject
import javax.inject.Singleton

import dispatch._
import org.json4s.DefaultFormats
import scala.util.parsing.json.JSONObject
import org.json4s.JValue
import scala.concurrent.Future
import scala.concurrent.ExecutionContext

trait PhabricatorClient {

/** Call a Conduit method with authentication enabled.
*
* @param method The Conduit method to call.
* @param params The parameters to pass to Conduit.
* @return The parsed response from Conduit
*/
def call(
method: String,
params: Map[String, Any]): Future[JValue]

}

@Singleton
class PhabricatorClientImpl @Inject() (
config: PhabricatorConfig)
(implicit ec: ExecutionContext) extends PhabricatorClient {

import PhabricatorClientImpl._

implicit val formats = DefaultFormats

val time = System.currentTimeMillis / 1000

// Get MD5 of (time + certificate)
val authSignatureMd5 = java.security.MessageDigest.getInstance("SHA-1")
authSignatureMd5.update((time.toString + config.certificate).getBytes)

// Format it correctly
val authSignature = authSignatureMd5.digest
.map(x =>"%02x".format(x)).mkString

// Now try to get session id
val connectResponse = call(
"conduit.connect",
Map(
"client" -> CLIENT_NAME,
"clientVersion" -> CLIENT_VERSION,
"user" -> config.user,
"authToken" -> time,
"authSignature" -> authSignature,
"host" -> config.apiUrl),
false).apply()

val sessionKey = (connectResponse \ "result" \ "sessionKey").extract[String]
val connectionID = (connectResponse \ "result" \ "connectionID").extract[Int]

override def call(
method: String,
params: Map[String, Any]): Future[JValue] =
call(method, params, true)

private def call(
method: String,
params: Map[String, Any],
authenticated: Boolean): Future[JValue] = {

// Convert to json string
val jsonString = JSONObject(
if (authenticated) {
params ++ Map(
"__conduit__" -> JSONObject(Map(
"sessionKey" -> sessionKey,
"connectionID" -> connectionID
))
)
} else {
params
}).toString()

// Make the request
val request = url(config.apiUrl) / method << Map(
"params" -> jsonString
)

// Return the response as json value
Http(request > as.json4s.Json)
}
}

object PhabricatorClientImpl {
final val CLIENT_NAME = "phab-metrics"
final val CLIENT_VERSION = 1
}
10 changes: 10 additions & 0 deletions app/phabricator/PhabricatorConfig.scala
@@ -0,0 +1,10 @@
package phabricator

case class PhabricatorConfig(
apiUrl: String,
user: String,
certificate: String) {
require(apiUrl.endsWith("/api"), s"Api url ${apiUrl} should end with api")
}


121 changes: 121 additions & 0 deletions app/phabricator/PhabricatorQuery.scala
@@ -0,0 +1,121 @@
package phabricator

import javax.inject.Inject
import javax.inject.Singleton

import org.joda.time.DateTime
import org.joda.time.Duration
import org.json4s.JsonAST._

import scala.util.parsing.json.JSONArray
import scala.concurrent.Future
import scala.concurrent.Await
import scala.concurrent.ExecutionContext
import scala.concurrent.duration._

case class Review (
reviewUrl: String,
authorPhid: String,
reviewersPhids: List[String],
createdAt: DateTime,
committedAt: Option[DateTime]) {

// TODO: Only count weekdays
val daysDifference =
new Duration(createdAt, committedAt.getOrElse(DateTime.now)).getStandardDays
}

trait PhabricatorQuery {

def getPhidForUsername(usernames: List[String]): Future[Map[String, String]]

/**
* Migrate to Guava multimap (because Scala one is useless)
*/
def getAllReviewsFromAuthorPhids(
authorPhid: List[String],
createdFrom: DateTime): Future[List[(String, Review)]]
}

@Singleton
class PhabricatorQueryImpl @Inject() (
client: PhabricatorClient)
(implicit ec: ExecutionContext) extends PhabricatorQuery {

override def getPhidForUsername(
usernames: List[String]): Future[Map[String, String]] = {
for {
response <- client.call(
"user.query",
Map("usernames" -> JSONArray(usernames)))
} yield {
val results: List[(String, String)] = for {
JObject(o) <- (response \ "result");
JField("userName", JString(userName)) <- o;
JField("phid", JString(phid)) <- o
} yield {
(userName -> phid)
}
results.toMap
}
}

override def getAllReviewsFromAuthorPhids(
authorPhids: List[String],
createdFrom: DateTime): Future[List[(String, Review)]] = {
for {
response <- client.call(
"differential.query",
Map("order" -> "order-created",
"authors" -> JSONArray(authorPhids)))
} yield {
val result: List[(String, Review)] = for {
JObject(o) <- (response \ "result");

// Get all the fields
JField("uri", JString(uri)) <- o;
JField("authorPHID", JString(authorPhid)) <- o;
JField("reviewers", JArray(reviewersList)) <- o;
JField("dateCreated", JString(createdAtString)) <- o;
JField("dateModified", JString(modifiedAtString)) <- o;
JField("status", JString(statusString)) <- o

// Convert them as necessary
createdAt = new DateTime(createdAtString.toLong * 1000);
modifiedAt = new DateTime(modifiedAtString.toLong * 1000);
reviewers = reviewersList.map(_.values.toString)
status = DiffStatus.find(statusString)

if status.isDefined &&
DiffStatus.ALLOWED_LIST.contains(status.get) &&
createdAt.isAfter(createdFrom)

} yield {

val review = status.get match {
case DiffStatus.NEEDS_REVIEW =>
Review(uri, authorPhid, reviewers, createdAt, None)
case DiffStatus.ACCEPTED | DiffStatus.CLOSED =>
Review(uri, authorPhid, reviewers, createdAt, Some(modifiedAt))
case _ =>
throw new IllegalArgumentException("This state should never happen as we filter")
}
(authorPhid -> review)
}
result
}
}
}

object DiffStatus extends Enumeration {
val NEEDS_REVIEW = Value("0")
val NEEDS_REVISION = Value("1")
val ACCEPTED = Value("2")
val CLOSED = Value("3")
val ABANDONED = Value ("4")

val lookup = DiffStatus.values.map(e => e.toString -> e).toMap
def find(e: String) = lookup.get(e)

val ALLOWED_LIST = List(NEEDS_REVIEW, ACCEPTED, CLOSED)
}

0 comments on commit 51db60e

Please sign in to comment.