Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Skeleton project to build a review report from Phabricator endpoints
- Loading branch information
Roshan Sumbaly
committed
Jun 29, 2015
1 parent
2c8a379
commit 51db60e
Showing
19 changed files
with
816 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,14 @@ | ||
logs | ||
project/project | ||
project/target | ||
target | ||
.history | ||
dist | ||
/.idea | ||
/*.iml | ||
/out | ||
/.idea_modules | ||
/.classpath | ||
/.project | ||
/.settings | ||
.DS_Store |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) { } | ||
|
||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) | ||
} | ||
|
||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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}") | ||
} | ||
} | ||
} | ||
} | ||
|
||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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") | ||
} | ||
|
||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) | ||
} |
Oops, something went wrong.