Skip to content

Instantly share code, notes, and snippets.

@Yyukan
Last active July 29, 2017 07:00
Show Gist options
  • Save Yyukan/a79a61fda32f373c26b2a1cd48479ac1 to your computer and use it in GitHub Desktop.
Save Yyukan/a79a61fda32f373c26b2a1cd48479ac1 to your computer and use it in GitHub Desktop.
JIRA API to retrieve sprint information
package models
import play.api.libs.json._
import play.api.{Logger, Play}
import scala.util.{Try, Failure, Success}
import play.api.libs.json.JsString
import scala.Some
import play.api.libs.json.JsNumber
import play.api.libs.json.JsObject
import play.api.libs.ws.{Response, WS}
import com.ning.http.client.Realm.AuthScheme
import scala.concurrent.Future
import scala.concurrent.duration._
import scala.concurrent.Await
import scala.collection.immutable.TreeMap
/**
* Simple Jira API
*
* Communicates with Jira to retrieve sprint information etc
* Uses basic authentication.
*/
class JiraAPI(url: String, user: String, password: String) {
/**
* Sprint details value object, contains sprint attributes like name, story point
* and burn down chart series. Lately this information is sent to the client side in JSON¶
*/
case class SprintInfo(id: Long, name: String, all: Float, open: Float, progress: Float, test: Float, done:Float,
startTime: Long, endTime: Long, burndown: Map[Long, Float])
/**
* JSON serializer for SprintDetails
*/
implicit object SprintInfoFormat extends Format[SprintInfo] {
/**
* Constructs sprint information from JSON
*
* @param json - json
* @return SprintDetails object
*/
override def reads(json: JsValue): JsResult[SprintInfo] = {
def parseStoryPoints(json: Seq[JsValue], status: String): Float = {
json.filter(issue => (issue \ "statusName").as[String] == status).foldLeft(0.0f)(
(points: Float, issue: JsValue) => {
(issue \ "estimateStatistic" \ "statFieldValue" \ "value").validate[Float] match {
case JsSuccess(value, _) => points + value
case JsError(error) => Logger.error(s"Issue $issue error $error"); points
}
}
)
}
/**
* Parses burndown JSON to map like
* 'date as milliseconds' -> 'story points as float'
*
* @param json
* @return
*/
def parseSprintBurndown(json :JsValue): Map[Long, Float] = {
def asLong(milliseconds: String): Long = java.lang.Long.parseLong(milliseconds)
/**
* Calculates sprint capacity (sum of all sprint points)
*/
def capacity(stories: Map[String, Float]): Float = {
stories.foldLeft(0.0f) { case (accumulator, (issue, points)) => accumulator + points }
}
/**
* Creates map of all stories (story -> story points)
*/
def sprintStories(changes: TreeMap[String, JsValue]): Map[String, Float] = {
def parseChange(change: JsValue):Map[String, Float] = {
val issue = (change \ "key").as[String]
(change \ "statC" \ "newValue").validate[Float] match {
case JsSuccess(value, _) => Map(issue -> value)
case _ => Map.empty
}
}
changes.flatMap { case (date, issue) =>
issue.as[Seq[JsValue]].toList match {
case head :: Nil => parseChange(head)
case _ => Nil
}
}
}
/**
* Creates map of burndown series (date -> left story points)
*/
def burndownSeries(changes: TreeMap[String, JsValue], start:Map[String, Float]): Map[Long, Float] = {
// amount of story point to burn
var amount:Float = capacity(start)
// stories could be added to the sprint
val stories = scala.collection.mutable.Map[String, Float](start.toSeq: _*)
def parseChange(date:Long, change:JsValue):Map[Long, Float] = {
val issue = (change \ "key").as[String]
(change \ "sprint").validate[Boolean] match {
// issue added to sprint
case JsSuccess(true, _) => //amount += stories(issue)
// issue removed from sprint
case JsSuccess(false, _) => amount -= stories(issue)
case _ => // skip any error
}
(change \ "column" \ "done").validate[Boolean] match {
// issue completed
case JsSuccess(true, _) => amount -= stories(issue)
// issue reopened
case JsSuccess(false, _) => amount += stories(issue)
case _ => // skip any error
}
(change \ "statC" \ "oldValue").validate[Float] match {
// estimation has been changed
case JsSuccess(value, _) => {
val newEstimate = (change \ "statC" \ "newValue").as[Float]
stories(issue) = newEstimate
amount -= (value - newEstimate)
}
case _ =>
(change \ "statC" \ "newValue").validate[Float] match {
// issue has been added to the sprint
case JsSuccess(value, _) => {
val estimate = (change \ "statC" \ "newValue").as[Float]
stories.put(issue, estimate)
amount += stories(issue)
}
case _ => // skip any error
}
}
Map(date -> amount)
}
changes.flatMap { case (date, issue) =>
issue.as[Seq[JsValue]].toList match {
case head :: Nil => parseChange(asLong(date), head)
case _ => Map.empty[Long, Float]
}
}
}
// sprint start date in milliseconds
val startSprint = (json \ "burndownchart" \ "startTime").as[Long]
// sort burndown changes by key (date)
val changes:TreeMap[String, JsValue] = TreeMap((json \ "burndownchart" \ "changes").as[JsObject].value.toArray:_*)
// parse all events before sprint to get all stories with estimation
val stories: Map[String, Float] = sprintStories(changes.filter(p => asLong(p._1) < startSprint))
// calculate sprint capacity as sum of all defined stories
val sprintCapacity = capacity(stories)
stories.foreach {
case (key, value) => println (key + " " + value)
}
// parse only sprint events to create burndown chart series
val series = burndownSeries(changes.filter(p => asLong(p._1) >= startSprint), stories)
// result is sorted by date
TreeMap(startSprint -> sprintCapacity) ++ series
}
val completedIssues = (json \ "sprintreport" \ "contents" \ "completedIssues").as[Seq[JsValue]]
val incompletedIssues = (json \ "sprintreport" \ "contents" \ "incompletedIssues").as[Seq[JsValue]]
JsSuccess(SprintInfo(
(json \ "sprintreport" \ "sprint" \ "id").as[Long],
(json \ "sprintreport" \ "sprint" \ "name").as[String],
(json \ "sprintreport" \ "contents" \ "allIssuesEstimateSum" \ "value").as[Float],
parseStoryPoints(incompletedIssues, "Open"),
parseStoryPoints(incompletedIssues, "In Progress"),
parseStoryPoints(incompletedIssues, "Resolved"),
parseStoryPoints(completedIssues, "Closed"),
(json \ "burndownchart" \ "startTime").as[Long],
(json \ "burndownchart" \ "endTime").as[Long],
parseSprintBurndown(json)
))
}
/**
* Serializes sprint information into JSON
* @return json
*/
override def writes(sprint: SprintInfo): JsValue = Json.obj(
"id" -> JsNumber(sprint.id),
"name" -> JsString(sprint.name),
"all" -> JsNumber(sprint.all),
"open" -> JsNumber(sprint.open),
"progress" -> JsNumber(sprint.progress),
"test" -> JsNumber(sprint.test),
"done" -> JsNumber(sprint.done),
"startTime" -> JsNumber(sprint.startTime),
"endTime" -> JsNumber(sprint.endTime),
"burndown" -> Json.arr(sprint.burndown.map {
case (date, storypoints) => Json.obj("date" -> date, "points" -> storypoints)
})
)
}
/**
* Returns all teams registered on all rapid views
* Useful to organize auto-completion on the admin page
* @return
*/
def teamsRapidViews():Option[Seq[String]] = {
def parse(json: JsValue):Option[Seq[String]] = {
val rapidView: Seq[String] = (json \ "success").as[Seq[JsValue]].map(value => (value \ "name").as[String])
rapidView match {
case Nil => Logger.error("No rapid views found"); None
case x => Some(x)
}
}
fetch(s"${url}rapidview") match {
case Success(data) => parse(data)
case Failure(error) => Logger.error(error.getMessage); None
}
}
/**
* Finds rapid view id for specified team
* @param team - team as simple string
* @return id of the rapid view or None
*/
def rapidViewId(team: String):Option[Long] = {
def parse(json: JsValue, team: String):Option[Long] = {
val rapidView = (json \ "success").as[Seq[JsValue]].filter(value => (value \ "name").as[String] == team)
rapidView match {
case Nil => Logger.error(s"No rapid view id found for team [$team]"); None
case x :: Nil => Some((x \ "id").as[Long])
case _ => Logger.error(s"More then one rapid view existed for team [$team]"); None
}
}
fetch(s"${url}rapidview") match {
case Success(data) => parse(data, team)
case Failure(error) => Logger.error(error.getMessage); None
}
}
/**
* Returns current (not closed) sprint id by rapid view id
* @param rapidViewId - specified rapid view id
* @return sprint id or None
*/
def currentSprintId(rapidViewId: Long):Option[Long] = {
def parseSprints(json: JsValue): Option[Long] = {
// filter all sprints to define only one which is not closed
val sprint = (json \ "sprints").as[Seq[JsValue]].filter(value => !(value \ "closed").as[Boolean])
sprint match {
case Nil => Logger.error(s"No sprint found for rapid view [$rapidViewId]"); None
case x :: Nil => Some((x \ "id").as[Long])
case _ => Logger.error(s"More then one sprint is not closed for rapid view [$rapidViewId]"); None
}
}
fetch(s"${url}sprints/$rapidViewId") match {
case Success(data) => parseSprints(data)
case Failure(error) => Logger.error(error.getMessage); None
}
}
/**
* Fetches details of burndown chart for rapid view and sprint
* @param rapidViewId - specified rapid view id
* @param sprintId - specified sprint id
*/
def burnDownDetails(rapidViewId: Long, sprintId: Long):JsValue = {
fetch(s"${url}rapid/charts/scopechangeburndownchart?rapidViewId=$rapidViewId&sprintId=$sprintId") match {
case Success(json) => json
case Failure(error) => Logger.error(error.getMessage); Json.obj()
}
}
def sprintDetails(rapidViewId: Long, sprintId: Long):Option[SprintInfo] = {
fetch(s"${url}rapid/charts/sprintreport?rapidViewId=$rapidViewId&sprintId=$sprintId") match {
case Success(json: JsValue) => {
Json.fromJson[SprintInfo](
// combine results of two queries together and than parse
Json.obj(
"sprintreport" -> json,
"burndownchart" -> burnDownDetails(rapidViewId, sprintId))
).asOpt
}
case Failure(error) => Logger.error(error.getMessage); None
}
}
/**
* Returns sprint details for specified team serialized as JSON
*
* @param team - team name as simple string, for example 'Front-end Team'
* @return SprintInfo class as JSON
*
* @see SprintInfoFormat
*/
def sprintDetails(team: String):Option[JsValue] = {
for {
viewId <- rapidViewId(team)
sprintId <- currentSprintId(viewId)
} yield Json.toJson(sprintDetails(viewId, sprintId))
}
/**
* Make a REST call to specified URL
* @param link - rest URL
* @return - response body as Json
*/
def fetch(link:String): Try[JsValue] = Try {
val response: Future[Response] = WS.url(link).withAuth(user, password, AuthScheme.BASIC).get()
val result: Response = Await.result(response, 5 seconds)
Logger.debug(s"Fetched [$link] " + result.statusText)
result.json
}
}
/**
* Companion object for the JiraAPI
*/
object JiraAPI {
lazy val conf = Play.current.configuration
lazy val JIRA_URL = conf.getString("jira.url").get
lazy val JIRA_USER = conf.getString("jira.user").get
lazy val JIRA_PASSWORD = conf.getString("jira.password").get
def apply():JiraAPI = new JiraAPI(JIRA_URL, JIRA_USER, JIRA_PASSWORD)
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment