Last active
July 29, 2017 07:00
-
-
Save Yyukan/a79a61fda32f373c26b2a1cd48479ac1 to your computer and use it in GitHub Desktop.
JIRA API to retrieve sprint information
This file contains hidden or 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
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