Christopher Johnson (WMDE) has submitted this change and it was merged.
Change subject: Add points to Tasks Table Change base URLs Add
SprintReportController Class Add Nav Menu to BurndownListController Change-Id:
I9148971fb50a407cb843efd046817855a2365e9e
......................................................................
Add points to Tasks Table
Change base URLs
Add SprintReportController Class
Add Nav Menu to BurndownListController
Change-Id: I9148971fb50a407cb843efd046817855a2365e9e
---
M __phutil_library_map__.php
M src/BurndownActionMenuEventListener.php
M src/BurndownApplication.php
M src/BurndownData.php
M src/BurndownListController.php
A src/SprintReportController.php
6 files changed, 1,031 insertions(+), 195 deletions(-)
Approvals:
Christopher Johnson (WMDE): Verified; Looks good to me, approved
diff --git a/__phutil_library_map__.php b/__phutil_library_map__.php
index b49e603..d3477bd 100644
--- a/__phutil_library_map__.php
+++ b/__phutil_library_map__.php
@@ -20,6 +20,7 @@
'SprintStartDateField' => 'src/SprintStartDateField.php',
'SprintEndDateField' => 'src/SprintEndDateField.php',
'SprintProjectCustomField' => 'src/SprintProjectCustomField.php',
+ 'SprintReportController' => 'src/SprintReportController.php',
'SprintTaskStoryPointsField' => 'src/SprintTaskStoryPointsField.php',
),
'function' => array(),
@@ -36,6 +37,7 @@
'PhabricatorProjectCustomField',
'PhabricatorStandardCustomFieldInterface',
),
+ 'SprintReportController' => 'ManiphestController',
'SprintTaskStoryPointsField' => array(
'ManiphestCustomField',
'PhabricatorStandardCustomFieldInterface',
diff --git a/src/BurndownActionMenuEventListener.php
b/src/BurndownActionMenuEventListener.php
index f528e15..1422171 100644
--- a/src/BurndownActionMenuEventListener.php
+++ b/src/BurndownActionMenuEventListener.php
@@ -37,7 +37,7 @@
$project = $event->getValue('object');
- $view_uri = '/burndown/view/'.$project->getId();
+ $view_uri = '/sprint/view/'.$project->getId();
return id(new PhabricatorActionView())
->setIcon('fa-bar-chart-o')
diff --git a/src/BurndownApplication.php b/src/BurndownApplication.php
index f81fae6..67455da 100644
--- a/src/BurndownApplication.php
+++ b/src/BurndownApplication.php
@@ -7,11 +7,11 @@
final class BurndownApplication extends PhabricatorApplication {
public function getName() {
- return pht('Burndown Extensions');
+ return pht('Sprint');
}
public function getBaseURI() {
- return '/burndown/list/';
+ return '/sprint/list/';
}
public function getIconName() {
@@ -30,8 +30,9 @@
public function getRoutes() {
return array(
- '/burndown/' => array(
+ '/sprint/' => array(
'list/' => 'BurndownListController',
+ 'report/(?:(?P<view>\w+)/)?' => 'SprintReportController',
'view/(?P<id>\d+)/' => 'BurndownController',
),
);
diff --git a/src/BurndownData.php b/src/BurndownData.php
index 10b8ae2..f9dd8ef 100644
--- a/src/BurndownData.php
+++ b/src/BurndownData.php
@@ -6,60 +6,62 @@
class BurndownData {
- // Array of BurndownDataDates
- // There are two special keys, 'before' and 'after'
- //
- // Looks like: array(
- // 'before' => BurndownDataDate
- // 'Tue Jun 3' => BurndownDataDate
- // 'Wed Jun 4' => BurndownDataDate
- // ...
- // 'after' => BurndownDataDate
- // )
+ // Array of BurndownDataDates
+ // There are two special keys, 'before' and 'after'
+ //
+ // Looks like: array(
+ // 'before' => BurndownDataDate
+ // 'Tue Jun 3' => BurndownDataDate
+ // 'Wed Jun 4' => BurndownDataDate
+ // ...
+ // 'after' => BurndownDataDate
+ // )
+ private $type_status = 'core:customfield';
+ private $storypoints;
private $dates;
private $data;
- // These hold an array of each task, and how many points are assigned, and
- // whether it's open or closed. These values change as we progress through
- // time, so that changes to points or status reflect on the graph.
+ // These hold an array of each task, and how many points are assigned, and
+ // whether it's open or closed. These values change as we progress through
+ // time, so that changes to points or status reflect on the graph.
private $task_points = array();
private $task_statuses = array();
- private $task_in_sprint= array();
- // Project associated with this burndown.
+ private $task_in_sprint = array();
+ // Project associated with this burndown.
private $project;
private $viewer;
private $tasks;
private $events;
private $xactions;
- public function __construct($project, $viewer)
- {
+ public function __construct($project, $viewer) {
$this->project = $project;
$this->viewer = $viewer;
// We need the custom fields so we can pull out the start and end date
- $aux_fields = $this->getAuxFields($project,$viewer);
- $start= $this->getStartDate($aux_fields);
+ $aux_fields = $this->getAuxFields($project, $viewer);
+ $start = $this->getStartDate($aux_fields);
$end = $this->getEndDate($aux_fields);
- $this->dates = $this->buildDateArray($start,$end);
+ $this->dates = $this->buildDateArray($start, $end);
- $tasks = $this->getTasks($project,$viewer);
+ $tasks = $this->getTasks($project, $viewer);
$this->checkNull($start, $end, $tasks);
- $xactions = $this->getXactions($tasks,$viewer);
+ $xactions = $this->getXactions($tasks, $viewer);
$this->examineXactions($xactions, $tasks);
- $this->buildDailyData($start,$end,$viewer);
+ $this->buildDailyData($start, $end, $viewer);
$this->buildTaskArrays();
+
$this->sumSprintStats();
$this->computeIdealPoints();
+
}
- private function getAuxFields($project, $viewer)
- {
+ private function getAuxFields($project, $viewer) {
$field_list = PhabricatorCustomField::getObjectFields($project,
PhabricatorCustomField::ROLE_EDIT);
$field_list->setViewer($viewer);
$field_list->readFieldsFromStorage($project);
@@ -67,27 +69,24 @@
return $aux_fields;
}
- private function getStartDate($aux_fields)
- {
+ private function getStartDate($aux_fields) {
$start = idx($aux_fields, 'isdc:sprint:startdate')
- ->getProxy()->getFieldValue();
+ ->getProxy()->getFieldValue();
return $start;
}
- private function getEndDate($aux_fields)
- {
+ private function getEndDate($aux_fields) {
$end = idx($aux_fields, 'isdc:sprint:enddate')
- ->getProxy()->getFieldValue();
+ ->getProxy()->getFieldValue();
return $end;
}
- private function buildDateArray ($start, $end)
- {
+ private function buildDateArray($start, $end) {
// Build an array of dates between start and end
$period = new DatePeriod(
- id(new DateTime("@".$start))->setTime(0,0),
+ id(new DateTime("@" . $start))->setTime(0, 0),
new DateInterval('P1D'), // 1 day interval
- id(new DateTime("@".$end))->modify('+1 day')->setTime(0,0));
+ id(new DateTime("@" . $end))->modify('+1 day')->setTime(0, 0));
$dates = array('before' => new BurndownDataDate('Start of Sprint'));
foreach ($period as $day) {
@@ -101,8 +100,7 @@
// Examine all the transactions and extract "events" out of them. These are
// times when a task was opened or closed. Make some effort to also track
// "scope" events (when a task was added or removed from a project).
- private function examineXactions($xactions,$tasks)
- {
+ private function examineXactions($xactions, $tasks) {
$scope_phids = array($this->project->getPHID());
$this->events = $this->extractEvents($xactions, $scope_phids);
@@ -119,35 +117,30 @@
// First, load *every task* in the project. We have to do something like
// this because there's no straightforward way to determine which tasks
// have activity in the project period.
- private function getTasks ($project, $viewer)
- {
+ private function getTasks($project, $viewer) {
$tasks = id(new ManiphestTaskQuery())
- ->setViewer($viewer)
- ->withAnyProjects(array($project->getPHID()))
- ->execute();
+ ->setViewer($viewer)
+ ->withAnyProjects(array($project->getPHID()))
+ ->execute();
return $tasks;
}
- private function checkNull ($start, $end, $tasks)
- {
- if (!$start OR !$end)
- {
- throw new BurndownException("This project is not set up for
Burndowns, "
- ."make sure it has 'Sprint' in the name, and then edit it to
add the "
- ."sprint start and end date.");
+ private function checkNull($start, $end, $tasks) {
+ if (!$start OR !$end) {
+ throw new BurndownException("This project is not set up for Burndowns, "
+ . "make sure it has 'Sprint' in the name, and then edit it to add
the "
+ . "sprint start and end date.");
}
- if (! $tasks)
- {
- throw new BurndownException("This project has no tasks.");
- }
+ if (!$tasks) {
+ throw new BurndownException("This project has no tasks.");
+ }
}
// Now load *every transaction* for those tasks. This loads all the
// comments, etc., for every one of the tasks. Again, not very fast, but
// we largely do not have ways to select this data more narrowly yet.
- private function getXactions ($tasks, $viewer)
- {
+ private function getXactions($tasks, $viewer) {
$task_phids = mpull($tasks, 'getPHID');
$xactions = id(new ManiphestTransactionQuery())
@@ -159,18 +152,17 @@
// Now that we have the data for each day, we need to loop over and sum
// up the relevant columns
- private function sumSprintStats ()
- {
+ private function sumSprintStats() {
$previous = null;
foreach ($this->dates as $current) {
$current->tasks_total = $current->tasks_added_today;
$current->points_total = $current->points_added_today;
- $current->tasks_remaining = $current->tasks_added_today;
+ $current->tasks_remaining = $current->tasks_added_today;
$current->points_remaining = $current->points_added_today;
if ($previous) {
$current->tasks_total += $previous->tasks_total;
$current->points_total += $previous->points_total;
- $current->tasks_remaining += $previous->tasks_remaining -
$current->tasks_closed_today;
+ $current->tasks_remaining += $previous->tasks_remaining -
$current->tasks_closed_today;
$current->points_remaining += $previous->points_remaining -
$current->points_closed_today;
}
$previous = $current;
@@ -180,11 +172,10 @@
// Build arrays to store current point and closed status of tasks as we
// progress through time, so that these changes reflect on the graph
- private function buildTaskArrays ()
- {
+ private function buildTaskArrays() {
$this->task_points = array();
$this->task_statuses = array();
- foreach($this->tasks as $task) {
+ foreach ($this->tasks as $task) {
$this->task_points[$task->getPHID()] = 0;
$this->task_statuses[$task->getPHID()] = null;
$this->task_in_sprint[$task->getPHID()] = 0;
@@ -193,8 +184,7 @@
}
// Now loop through the events and build the data for each day
- private function buildDailyData ($start, $end, $viewer)
- {
+ private function buildDailyData($start, $end, $viewer) {
foreach ($this->events as $event) {
$xaction = $this->xactions[$event['transactionPHID']];
@@ -212,7 +202,7 @@
$date = phabricator_format_local_time($xaction_date, $viewer, 'D M j');
}
- switch($event['type']) {
+ switch ($event['type']) {
case "create":
// Will be accounted for by "task-add" when the project is added
// Bet we still include it so it shows on the Events list
@@ -247,37 +237,36 @@
// This is a cheap hacky way to get business days, and does not account for
// holidays at all.
- private function computeIdealPoints()
- {
- $total_business_days = 0;
- foreach($this->dates as $key => $date) {
- if ($key == 'before' OR $key == 'after')
- continue;
- $day_of_week = id(new DateTime($date->getDate()))->format('w');
- if ($day_of_week != 0 AND $day_of_week != 6) {
- $total_business_days++;
- }
- }
+ private function computeIdealPoints() {
+ $total_business_days = 0;
+ foreach ($this->dates as $key => $date) {
+ if ($key == 'before' OR $key == 'after')
+ continue;
+ $day_of_week = id(new DateTime($date->getDate()))->format('w');
+ if ($day_of_week != 0 AND $day_of_week != 6) {
+ $total_business_days++;
+ }
+ }
$elapsed_business_days = 0;
- foreach($this->dates as $key => $date) {
- if ($key == 'before') {
- $date->points_ideal_remaining = $date->points_total;
- continue;
- } else if ($key == 'after') {
- $date->points_ideal_remaining = 0;
- continue;
- }
+ foreach ($this->dates as $key => $date) {
+ if ($key == 'before') {
+ $date->points_ideal_remaining = $date->points_total;
+ continue;
+ } else if ($key == 'after') {
+ $date->points_ideal_remaining = 0;
+ continue;
+ }
- $day_of_week = id(new DateTime($date->getDate()))->format('w');
- if ($day_of_week != 0 AND $day_of_week != 6) {
- $elapsed_business_days++;
- }
+ $day_of_week = id(new DateTime($date->getDate()))->format('w');
+ if ($day_of_week != 0 AND $day_of_week != 6) {
+ $elapsed_business_days++;
+ }
- $date->points_ideal_remaining = round($date->points_total *
- (1 - ($elapsed_business_days / $total_business_days)), 1);
- }
+ $date->points_ideal_remaining = round($date->points_total *
+ (1 - ($elapsed_business_days / $total_business_days)), 1);
}
+ }
/**
@@ -317,18 +306,17 @@
// Adjust points for that day
$this->dates[$date]->points_added_today +=
- $xaction->getNewValue() - $xaction->getOldValue();
+ $xaction->getNewValue() - $xaction->getOldValue();
// If the task is closed, adjust completed points as well
if ($this->task_statuses[$task_phid] == 'closed') {
$this->dates[$date]->points_closed_today +=
- $xaction->getNewValue() - $xaction->getOldValue();
+ $xaction->getNewValue() - $xaction->getOldValue();
}
}
}
- private function buildChartDataSet()
- {
+ private function buildChartDataSet() {
$data = array(array(
pht('Date'),
pht('Total Points'),
@@ -338,23 +326,23 @@
));
$future = false;
- foreach($this->dates as $key => $date)
- {
+ foreach ($this->dates as $key => $date) {
if ($key != 'before' AND $key != 'after') {
- $future = new DateTime($date->getDate()) > id(new
DateTime())->setTime(0,0);
+ $future = new DateTime($date->getDate()) > id(new
DateTime())->setTime(0, 0);
}
$data[] = array(
$date->getDate(),
- $future ? null: $date->points_total,
- $future ? null: $date->points_remaining,
+ $future ? null : $date->points_total,
+ $future ? null : $date->points_remaining,
$date->points_ideal_remaining,
- $future ? null: $date->points_closed_today,
+ $future ? null : $date->points_closed_today,
);
}
return $data;
}
+
public function buildBurnDownChart() {
$this->data = $this->buildChartDataSet();
@@ -364,11 +352,11 @@
// This should probably use celerity and/or javelin
$box = id(new PHUIObjectBoxView())
- ->setHeaderText(pht('Burndown for '.$this->project->getName()))
- // Calling phutil_safe_html and passing in <script> tags is a potential
- // security hole. None of this data is direct user input, so we should
- // be fine.
- ->appendChild(phutil_safe_html(<<<HERE
+ ->setHeaderText(pht('Burndown for ' . $this->project->getName()))
+ // Calling phutil_safe_html and passing in <script> tags is a potential
+ // security hole. None of this data is direct user input, so we should
+ // be fine.
+ ->appendChild(phutil_safe_html(<<<HERE
<script type="text/javascript" src="//www.google.com/jsapi"></script>
<script type="text/javascript">
google.load('visualization', '1', {packages: ['corechart']});
@@ -400,11 +388,11 @@
</script>
HERE
))
- ->appendChild(phutil_tag('div',
- array(
- 'id' => 'visualization',
- 'style' => 'width: 100%; height:400px'
- ),''));
+ ->appendChild(phutil_tag('div',
+ array(
+ 'id' => 'visualization',
+ 'style' => 'width: 100%; height:400px'
+ ), ''));
return $box;
}
@@ -416,7 +404,7 @@
*/
public function buildBurnDownTable() {
$data = array();
- foreach($this->dates as $date) {
+ foreach ($this->dates as $date) {
$data[] = array(
$date->getDate(),
$date->tasks_total,
@@ -425,24 +413,24 @@
$date->points_remaining,
$date->points_ideal_remaining,
$date->points_closed_today,
- );
+ );
}
$table = id(new AphrontTableView($data))
- ->setHeaders(
- array(
- pht('Date'),
- pht('Total Tasks'),
- pht('Remaining Tasks'),
- pht('Total Points'),
- pht('Remaining Points'),
- pht('Ideal Remaining Points'),
- pht('Points Completed Today'),
- ));
+ ->setHeaders(
+ array(
+ pht('Date'),
+ pht('Total Tasks'),
+ pht('Remaining Tasks'),
+ pht('Total Points'),
+ pht('Remaining Points'),
+ pht('Ideal Remaining Points'),
+ pht('Points Completed Today'),
+ ));
$box = id(new PHUIObjectBoxView())
- ->setHeaderText(pht('DATA'))
- ->appendChild($table);
+ ->setHeaderText(pht('DATA'))
+ ->appendChild($table);
return $box;
}
@@ -457,18 +445,18 @@
$rows = $this->buildTasksTree();
$table = id(new AphrontTableView($rows))
- ->setHeaders(
- array(
- pht('Task'),
- pht('Assigned to'),
- pht('Priority'),
- // pht('Points'),
- pht('Status'),
- ));
+ ->setHeaders(
+ array(
+ pht('Task'),
+ pht('Assigned to'),
+ pht('Priority'),
+ pht('Points'),
+ pht('Status'),
+ ));
$box = id(new PHUIObjectBoxView())
- ->setHeaderText(pht('Tasks in this Sprint'))
- ->appendChild($table);
+ ->setHeaderText(pht('Tasks in this Sprint'))
+ ->appendChild($table);
return $box;
}
@@ -488,9 +476,9 @@
// Load all edges of depends and depended on tasks
$edges = id(new PhabricatorEdgeQuery())
- ->withSourcePHIDs(array_keys($this->tasks))
- ->withEdgeTypes(array($DEPENDS_ON, $DEPENDED_ON))
- ->execute();
+ ->withSourcePHIDs(array_keys($this->tasks))
+ ->withEdgeTypes(array($DEPENDS_ON, $DEPENDED_ON))
+ ->execute();
// First we build a flat map. Each task is in the map at the root level,
// and lists it's parents and children.
@@ -516,15 +504,15 @@
// We also collect the phids we need to fetch owner information
$handle_phids = array();
- foreach($this->tasks as $task) {
+ foreach ($this->tasks as $task) {
// Get the owner (assigned to) phid
$handle_phids[$task->getOwnerPHID()] = $task->getOwnerPHID();
}
$handles = id(new PhabricatorHandleQuery())
- ->setViewer($this->viewer)
- ->withPHIDs($handle_phids)
- ->execute();
+ ->setViewer($this->viewer)
+ ->withPHIDs($handle_phids)
+ ->execute();
// Now we loop through the tasks, and add them to the output
$output = array();
@@ -541,8 +529,7 @@
return $output;
}
- private function addTaskToTree(&$output, $task, &$map, $handles,
- $depth = 0) {
+ private function addTaskToTree(&$output, $task, &$map, $handles, $depth = 0)
{
static $included = array();
// Get the owner object so we can render the owner username/link
@@ -550,36 +537,41 @@
// If this task is already is this tree, this is a repeat.
$repeat = isset($included[$task->getPHID()]);
+
+ $points_data = $this->getPointsData();
+ $points = $this->getTaskStoryPoints($task->getPHID(),$points_data);
+ $points = trim($points, '"');
+
$priority_name = new ManiphestTaskPriority();
- $depth_indent='';
- for($i=0; $i<$depth; $i++) {
- $depth_indent.=' ';
+ $depth_indent = '';
+ for ($i = 0; $i < $depth; $i++) {
+ $depth_indent .= ' ';
}
// Build the row
$output[] = array(
- phutil_safe_html($depth_indent.phutil_tag(
- 'a',
- array(
- 'href' => '/'.$task->getMonogram(),
- 'class' => $task->getStatus() !== 'open'
- ? 'phui-tag-core-closed'
- : '',
- ),
- $task->getMonogram().': '.$task->getTitle()
- ).($repeat? ' <em title="This task is a child of more than
one task in this list. Children are only shown on '.
- 'the first occurance">[Repeat]</em>':'')),
+ phutil_safe_html($depth_indent . phutil_tag(
+ 'a',
+ array(
+ 'href' => '/' . $task->getMonogram(),
+ 'class' => $task->getStatus() !== 'open'
+ ? 'phui-tag-core-closed'
+ : '',
+ ),
+ $task->getMonogram() . ': ' . $task->getTitle()
+ ) . ($repeat ? ' <em title="This task is a child of
more than one task in this list. Children are only shown on ' .
+ 'the first occurance">[Repeat]</em>' : '')),
$task->getOwnerPHID() ? $owner->renderLink() : 'none assigned',
$priority_name->getTaskPriorityName($task->getPriority()),
- // $task->getPoints(),
+ $points,
$task->getStatus(),
);
$included[$task->getPHID()] = $task->getPHID();
if (isset($map[$task->getPHID()]['children'])) {
- foreach($map[$task->getPHID()]['children'] as $child) {
+ foreach ($map[$task->getPHID()]['children'] as $child) {
$child = $this->tasks[$child];
- $this->addTaskToTree($output, $child, $map, $handles, $depth+1);
+ $this->addTaskToTree($output, $child, $map, $handles, $depth + 1);
}
}
}
@@ -589,46 +581,88 @@
*
* @returns PHUIObjectBoxView
*/
- public function buildEventTable()
- {
+ public function buildEventTable() {
$rows = array();
foreach ($this->events as $event) {
$task_phid = $this->xactions[$event['transactionPHID']]->getObjectPHID();
$task = $this->tasks[$task_phid];
$rows[] = array(
- phabricator_datetime($event['epoch'], $this->viewer),
- phutil_tag(
- 'a',
- array(
- 'href' => '/'.$task->getMonogram(),
- ),
- $task->getMonogram().': '.$task->getTitle()),
- $event['title'],
+ phabricator_datetime($event['epoch'], $this->viewer),
+ phutil_tag(
+ 'a',
+ array(
+ 'href' => '/' . $task->getMonogram(),
+ ),
+ $task->getMonogram() . ': ' . $task->getTitle()),
+ $event['title'],
);
}
$table = id(new AphrontTableView($rows))
- ->setHeaders(
- array(
- pht('When'),
- pht('Task'),
- pht('Action'),
- ))
- ->setColumnClasses(
- array(
- '',
- '',
- 'wide',
- ));
+ ->setHeaders(
+ array(
+ pht('When'),
+ pht('Task'),
+ pht('Action'),
+ ))
+ ->setColumnClasses(
+ array(
+ '',
+ '',
+ 'wide',
+ ));
$box = id(new PHUIObjectBoxView())
- ->setHeaderText(pht('Events related to this sprint'))
- ->appendChild($table);
+ ->setHeaderText(pht('Events related to this sprint'))
+ ->appendChild($table);
return $box;
}
+ private function getTaskStoryPoints($task,$points_data) {
+ $storypoints = array();
+ foreach ($points_data as $k=>$subarray) {
+ if (isset ($subarray['objectPHID']) && $subarray['objectPHID'] ==
$task) {
+ $points_data[$k] = $subarray;
+ $storypoints = $subarray['newValue'];
+ }
+ }
+ return $storypoints;
+ }
+
+
+ private function getPointsData () {
+ $handle = null;
+
+ $project_phid = $this->project->getPHID();
+ $table = new ManiphestTransaction();
+ $conn = $table->establishConnection('r');
+
+ $joins = '';
+ if ($project_phid) {
+ $joins = qsprintf(
+ $conn,
+ 'JOIN %T t ON x.objectPHID = t.phid
+ JOIN %T p ON p.src = t.phid AND p.type = %d AND p.dst = %s',
+ id(new ManiphestTask())->getTableName(),
+ PhabricatorEdgeConfig::TABLE_NAME_EDGE,
+ PhabricatorProjectObjectHasProjectEdgeType::EDGECONST,
+ $project_phid);
+ }
+
+ $points_data = queryfx_all(
+ $conn,
+ 'SELECT x.objectPHID, x.oldValue, x.newValue, x.dateCreated FROM %T x
%Q
+ WHERE transactionType = %s
+ ORDER BY x.dateCreated ASC',
+ $table->getTableName(),
+ $joins,
+ $this->type_status);
+
+ return $points_data;
+ }
+
/**
* Extract important events (the times when tasks were opened or closed)
* from a list of transactions.
diff --git a/src/BurndownListController.php b/src/BurndownListController.php
index fb18e0e..0e7e287 100644
--- a/src/BurndownListController.php
+++ b/src/BurndownListController.php
@@ -6,12 +6,24 @@
final class BurndownListController extends PhabricatorController {
+ private $view;
public function willProcessRequest(array $data) {
}
public function processRequest() {
$request = $this->getRequest();
$viewer = $request->getUser();
+
+ $nav = new AphrontSideNavFilterView();
+ $nav->setBaseURI(new PhutilURI('/sprint/report/'));
+ $nav->addLabel(pht('Open Tasks'));
+ $nav->addFilter('user', pht('By User'));
+ $nav->addFilter('project', pht('By Project'));
+ $nav->addLabel(pht('Burndown'));
+ $nav->addFilter('burn', pht('Burndown Rate'));
+
+ $this->view = $nav->selectFilter($this->view, 'user');
+
// Load all projects with "Sprint" in the name.
$projects = id(new PhabricatorProjectQuery())
@@ -38,7 +50,7 @@
$rows[] = array(
'project' => phutil_tag('a',
array(
- 'href' => '/burndown/view/'.$project->getId(),
+ 'href' => '/sprint/view/'.$project->getId(),
'style' => 'font-weight:bold',
),
$project->getName()
@@ -62,8 +74,10 @@
'date',
));
+
$crumbs = $this->buildApplicationCrumbs();
$crumbs->addTextCrumb(pht('Burndown List'));
+
$help = id(new PHUIBoxView())
->appendChild(phutil_tag('p', array(),
@@ -72,18 +86,24 @@
))
->addMargin(PHUI::MARGIN_LARGE);
- $box = id(new PHUIBoxView())
+ $box= id(new PHUIBoxView())
->appendChild($projects_table)
->addMargin(PHUI::MARGIN_LARGE);
+ $nav->appendChild(
+ array(
+ $crumbs,
+ $help,
+ $box,
+ ));
+
return $this->buildApplicationPage(
+
array(
- $crumbs,
- $help,
- $box,
+ $nav,
),
array(
- 'title' => array(pht('Burndown List')),
+ 'title' => array(pht('Sprint List')),
'device' => true,
));
}
diff --git a/src/SprintReportController.php b/src/SprintReportController.php
new file mode 100644
index 0000000..055ca3f
--- /dev/null
+++ b/src/SprintReportController.php
@@ -0,0 +1,779 @@
+<?php
+
+final class SprintReportController extends ManiphestController {
+
+ private $view;
+ //private $type_status = 'core:customfield';
+ public function willProcessRequest(array $data) {
+ $this->view = idx($data, 'view');
+ }
+
+ public function processRequest() {
+ $request = $this->getRequest();
+ $user = $request->getUser();
+
+ if ($request->isFormPost()) {
+ $uri = $request->getRequestURI();
+
+ $project = head($request->getArr('set_project'));
+ $project = nonempty($project, null);
+ $uri = $uri->alter('project', $project);
+
+ $window = $request->getStr('set_window');
+ $uri = $uri->alter('window', $window);
+
+ return id(new AphrontRedirectResponse())->setURI($uri);
+ }
+
+ $nav = new AphrontSideNavFilterView();
+ $nav->setBaseURI(new PhutilURI('/sprint/report/'));
+ $nav->addLabel(pht('Open Tasks'));
+ $nav->addFilter('project', pht('By Project'));
+ $nav->addFilter('user', pht('By User'));
+ $nav->addLabel(pht('Burndown'));
+ $nav->addFilter('burn', pht('Burndown Rate'));
+
+ $this->view = $nav->selectFilter($this->view, 'user');
+
+ require_celerity_resource('maniphest-report-css');
+
+ switch ($this->view) {
+ case 'burn':
+ $core = $this->renderBurn();
+ break;
+ case 'user':
+ case 'project':
+ $core = $this->renderOpenTasks();
+ break;
+ default:
+ return new Aphront404Response();
+ }
+
+ $nav->appendChild($core);
+ $nav->setCrumbs(
+ $this->buildApplicationCrumbs()
+ ->addTextCrumb(pht('Reports')));
+
+ return $this->buildApplicationPage(
+ $nav,
+ array(
+ 'title' => pht('Sprint Reports'),
+ 'device' => false,
+ ));
+ }
+
+ public function renderBurn() {
+
+
+ $request = $this->getRequest();
+ $user = $request->getUser();
+
+ $handle = null;
+
+ $project_phid = $request->getStr('project');
+ if ($project_phid) {
+ $phids = array($project_phid);
+ $handles = $this->loadViewerHandles($phids);
+ $handle = $handles[$project_phid];
+ }
+
+ $table = new ManiphestTransaction();
+ $conn = $table->establishConnection('r');
+
+ $joins = '';
+ if ($project_phid) {
+ $joins = qsprintf(
+ $conn,
+ 'JOIN %T t ON x.objectPHID = t.phid
+ JOIN %T p ON p.src = t.phid AND p.type = %d AND p.dst = %s',
+ id(new ManiphestTask())->getTableName(),
+ PhabricatorEdgeConfig::TABLE_NAME_EDGE,
+ PhabricatorProjectObjectHasProjectEdgeType::EDGECONST,
+ $project_phid);
+ }
+
+ $data = queryfx_all(
+ $conn,
+ 'SELECT x.objectPHID, x.oldValue, x.newValue, x.dateCreated FROM %T x
%Q
+ WHERE transactionType = %s
+ ORDER BY x.dateCreated ASC',
+ $table->getTableName(),
+ $joins,
+ ManiphestTransaction::TYPE_STATUS);
+
+ $stats = array();
+ $day_buckets = array();
+
+ $open_tasks = array();
+
+ foreach ($data as $key => $row) {
+
+ // NOTE: Hack to avoid json_decode().
+ $oldv = trim($row['oldValue'], '"');
+ $newv = trim($row['newValue'], '"');
+
+ if ($oldv == 'null') {
+ $old_is_open = false;
+ } else {
+ $old_is_open = ManiphestTaskStatus::isOpenStatus($oldv);
+ }
+
+ $new_is_open = ManiphestTaskStatus::isOpenStatus($newv);
+
+ $is_open = ($new_is_open && !$old_is_open);
+ $is_close = ($old_is_open && !$new_is_open);
+
+ $data[$key]['_is_open'] = $is_open;
+ $data[$key]['_is_close'] = $is_close;
+
+ if (!$is_open && !$is_close) {
+ // This is either some kind of bogus event, or a resolution change
+ // (e.g., resolved -> invalid). Just skip it.
+ continue;
+ }
+
+ $day_bucket = phabricator_format_local_time(
+ $row['dateCreated'],
+ $user,
+ 'Yz');
+ $day_buckets[$day_bucket] = $row['dateCreated'];
+ if (empty($stats[$day_bucket])) {
+ $stats[$day_bucket] = array(
+ 'open' => 0,
+ 'close' => 0,
+ );
+ }
+ $stats[$day_bucket][$is_close ? 'close' : 'open']++;
+ }
+
+ $template = array(
+ 'open' => 0,
+ 'close' => 0,
+ );
+
+ $rows = array();
+ $rowc = array();
+ $last_month = null;
+ $last_month_epoch = null;
+ $last_week = null;
+ $last_week_epoch = null;
+ $week = null;
+ $month = null;
+
+ $last = last_key($stats) - 1;
+ $period = $template;
+
+ foreach ($stats as $bucket => $info) {
+ $epoch = $day_buckets[$bucket];
+
+ $week_bucket = phabricator_format_local_time(
+ $epoch,
+ $user,
+ 'YW');
+ if ($week_bucket != $last_week) {
+ if ($week) {
+ $rows[] = $this->formatBurnRow(
+ 'Week of '.phabricator_date($last_week_epoch, $user),
+ $week);
+ $rowc[] = 'week';
+ }
+ $week = $template;
+ $last_week = $week_bucket;
+ $last_week_epoch = $epoch;
+ }
+
+ $month_bucket = phabricator_format_local_time(
+ $epoch,
+ $user,
+ 'Ym');
+ if ($month_bucket != $last_month) {
+ if ($month) {
+ $rows[] = $this->formatBurnRow(
+ phabricator_format_local_time($last_month_epoch, $user, 'F, Y'),
+ $month);
+ $rowc[] = 'month';
+ }
+ $month = $template;
+ $last_month = $month_bucket;
+ $last_month_epoch = $epoch;
+ }
+
+ $rows[] = $this->formatBurnRow(phabricator_date($epoch, $user), $info);
+ $rowc[] = null;
+ $week['open'] += $info['open'];
+ $week['close'] += $info['close'];
+ $month['open'] += $info['open'];
+ $month['close'] += $info['close'];
+ $period['open'] += $info['open'];
+ $period['close'] += $info['close'];
+ }
+
+ if ($week) {
+ $rows[] = $this->formatBurnRow(
+ pht('Week To Date'),
+ $week);
+ $rowc[] = 'week';
+ }
+
+ if ($month) {
+ $rows[] = $this->formatBurnRow(
+ pht('Month To Date'),
+ $month);
+ $rowc[] = 'month';
+ }
+
+ $rows[] = $this->formatBurnRow(
+ pht('All Time'),
+ $period);
+ $rowc[] = 'aggregate';
+
+ $rows = array_reverse($rows);
+ $rowc = array_reverse($rowc);
+
+ $table = new AphrontTableView($rows);
+ $table->setRowClasses($rowc);
+ $table->setHeaders(
+ array(
+ pht('Period'),
+ pht('Opened'),
+ pht('Closed'),
+ pht('Change'),
+ ));
+ $table->setColumnClasses(
+ array(
+ 'left narrow',
+ 'center narrow',
+ 'center narrow',
+ 'center narrow',
+ ));
+
+ if ($handle) {
+ $inst = pht(
+ 'NOTE: This table reflects tasks currently in '.
+ 'the project. If a task was opened in the past but added to '.
+ 'the project recently, it is counted on the day it was '.
+ 'opened, not the day it was categorized. If a task was part '.
+ 'of this project in the past but no longer is, it is not '.
+ 'counted at all.');
+ $header = pht('Task Burn Rate for Project %s', $handle->renderLink());
+ $caption = phutil_tag('p', array(), $inst);
+ } else {
+ $header = pht('Task Burn Rate for All Tasks');
+ $caption = null;
+ }
+
+ if ($caption) {
+ $caption = id(new AphrontErrorView())
+ ->appendChild($caption)
+ ->setSeverity(AphrontErrorView::SEVERITY_NOTICE);
+ }
+
+ $panel = new PHUIObjectBoxView();
+ $panel->setHeaderText($header);
+ if ($caption) {
+ $panel->setErrorView($caption);
+ }
+ $panel->appendChild($table);
+
+ $tokens = array();
+ if ($handle) {
+ $tokens = array($handle);
+ }
+
+ $filter = $this->renderReportFilters($tokens, $has_window = false);
+
+ $id = celerity_generate_unique_node_id();
+ $chart = phutil_tag(
+ 'div',
+ array(
+ 'id' => $id,
+ 'style' => 'border: 1px solid #BFCFDA; '.
+ 'background-color: #fff; '.
+ 'margin: 8px 16px; '.
+ 'height: 400px; ',
+ ),
+ '');
+
+ list($burn_x, $burn_y) = $this->buildSeries($data);
+
+ require_celerity_resource('raphael-core');
+ require_celerity_resource('raphael-g');
+ require_celerity_resource('raphael-g-line');
+
+ Javelin::initBehavior('line-chart', array(
+ 'hardpoint' => $id,
+ 'x' => array(
+ $burn_x,
+ ),
+ 'y' => array(
+ $burn_y,
+ ),
+ 'xformat' => 'epoch',
+ 'yformat' => 'int',
+ ));
+
+ return array($filter, $chart, $panel);
+ }
+
+ private function renderReportFilters(array $tokens, $has_window) {
+ $request = $this->getRequest();
+ $user = $request->getUser();
+
+ $form = id(new AphrontFormView())
+ ->setUser($user)
+ ->appendChild(
+ id(new AphrontFormTokenizerControl())
+ ->setDatasource(new PhabricatorProjectDatasource())
+ ->setLabel(pht('Project'))
+ ->setLimit(1)
+ ->setName('set_project')
+ ->setValue($tokens));
+
+ if ($has_window) {
+ list($window_str, $ignored, $window_error) = $this->getWindow();
+ $form
+ ->appendChild(
+ id(new AphrontFormTextControl())
+ ->setLabel(pht('Recently Means'))
+ ->setName('set_window')
+ ->setCaption(
+ pht('Configure the cutoff for the "Recently Closed"
column.'))
+ ->setValue($window_str)
+ ->setError($window_error));
+ }
+
+ $form
+ ->appendChild(
+ id(new AphrontFormSubmitControl())
+ ->setValue(pht('Filter By Project')));
+
+ $filter = new AphrontListFilterView();
+ $filter->appendChild($form);
+
+ return $filter;
+ }
+
+ private function buildSeries(array $data) {
+ $out = array();
+
+ $counter = 0;
+ foreach ($data as $row) {
+ $t = (int)$row['dateCreated'];
+ if ($row['_is_close']) {
+ --$counter;
+ $out[$t] = $counter;
+ } else if ($row['_is_open']) {
+ ++$counter;
+ $out[$t] = $counter;
+ }
+ }
+
+ return array(array_keys($out), array_values($out));
+ }
+
+ private function formatBurnRow($label, $info) {
+ $delta = $info['open'] - $info['close'];
+ $fmt = number_format($delta);
+ if ($delta > 0) {
+ $fmt = '+'.$fmt;
+ $fmt = phutil_tag('span', array('class' => 'red'), $fmt);
+ } else {
+ $fmt = phutil_tag('span', array('class' => 'green'), $fmt);
+ }
+
+ return array(
+ $label,
+ number_format($info['open']),
+ number_format($info['close']),
+ $fmt);
+ }
+
+ public function renderOpenTasks() {
+ $request = $this->getRequest();
+ $user = $request->getUser();
+
+
+ $query = id(new ManiphestTaskQuery())
+ ->setViewer($user)
+ ->withStatuses(ManiphestTaskStatus::getOpenStatusConstants());
+
+ $project_phid = $request->getStr('project');
+ $project_handle = null;
+ if ($project_phid) {
+ $phids = array($project_phid);
+ $handles = $this->loadViewerHandles($phids);
+ $project_handle = $handles[$project_phid];
+
+ $query->withAnyProjects($phids);
+ }
+
+ $tasks = $query->execute();
+
+ $recently_closed = $this->loadRecentlyClosedTasks();
+
+ $date = phabricator_date(time(), $user);
+
+ switch ($this->view) {
+ case 'user':
+ $result = mgroup($tasks, 'getOwnerPHID');
+ $leftover = idx($result, '', array());
+ unset($result['']);
+
+ $result_closed = mgroup($recently_closed, 'getOwnerPHID');
+ $leftover_closed = idx($result_closed, '', array());
+ unset($result_closed['']);
+
+ $base_link = '/maniphest/?assigned=';
+ $leftover_name = phutil_tag('em', array(), pht('(Up For Grabs)'));
+ $col_header = pht('User');
+ $header = pht('Open Tasks by User and Priority (%s)', $date);
+ break;
+ case 'project':
+ $result = array();
+ $leftover = array();
+ foreach ($tasks as $task) {
+ $phids = $task->getProjectPHIDs();
+ if ($phids) {
+ foreach ($phids as $project_phid) {
+ $result[$project_phid][] = $task;
+ }
+ } else {
+ $leftover[] = $task;
+ }
+ }
+
+ $result_closed = array();
+ $leftover_closed = array();
+ foreach ($recently_closed as $task) {
+ $phids = $task->getProjectPHIDs();
+ if ($phids) {
+ foreach ($phids as $project_phid) {
+ $result_closed[$project_phid][] = $task;
+ }
+ } else {
+ $leftover_closed[] = $task;
+ }
+ }
+
+ $base_link = '/maniphest/?allProjects=';
+ $leftover_name = phutil_tag('em', array(), pht('(No Project)'));
+ $col_header = pht('Project');
+ $header = pht('Open Tasks by Project and Priority (%s)', $date);
+ break;
+ }
+
+ $phids = array_keys($result);
+ $handles = $this->loadViewerHandles($phids);
+ $handles = msort($handles, 'getName');
+
+ $order = $request->getStr('order', 'name');
+ list($order, $reverse) = AphrontTableView::parseSort($order);
+
+ require_celerity_resource('aphront-tooltip-css');
+ Javelin::initBehavior('phabricator-tooltips', array());
+
+ $rows = array();
+ $pri_total = array();
+ foreach (array_merge($handles, array(null)) as $handle) {
+ if ($handle) {
+ if (($project_handle) &&
+ ($project_handle->getPHID() == $handle->getPHID())) {
+ // If filtering by, e.g., "bugs", don't show a "bugs" group.
+ continue;
+ }
+
+ $tasks = idx($result, $handle->getPHID(), array());
+ $name = phutil_tag(
+ 'a',
+ array(
+ 'href' => $base_link.$handle->getPHID(),
+ ),
+ $handle->getName());
+ $closed = idx($result_closed, $handle->getPHID(), array());
+ } else {
+ $tasks = $leftover;
+ $name = $leftover_name;
+ $closed = $leftover_closed;
+ }
+
+ $taskv = $tasks;
+ $tasks = mgroup($tasks, 'getPriority');
+
+ $row = array();
+ $row[] = $name;
+ $total = 0;
+ foreach (ManiphestTaskPriority::getTaskPriorityMap() as $pri => $label) {
+ $n = count(idx($tasks, $pri, array()));
+ if ($n == 0) {
+ $row[] = '-';
+ } else {
+ $row[] = number_format($n);
+ }
+ $total += $n;
+ }
+ $row[] = number_format($total);
+
+ list($link, $oldest_all) = $this->renderOldest($taskv);
+ $row[] = $link;
+
+ $normal_or_better = array();
+ foreach ($taskv as $id => $task) {
+ // TODO: This is sort of a hard-code for the default "normal" status.
+ // When reports are more powerful, this should be made more general.
+ if ($task->getPriority() < 50) {
+ continue;
+ }
+ $normal_or_better[$id] = $task;
+ }
+
+ list($link, $oldest_pri) = $this->renderOldest($normal_or_better);
+ $row[] = $link;
+
+ if ($closed) {
+ $task_ids = implode(',', mpull($closed, 'getID'));
+ $row[] = phutil_tag(
+ 'a',
+ array(
+ 'href' => '/maniphest/?ids='.$task_ids,
+ 'target' => '_blank',
+ ),
+ number_format(count($closed)));
+ } else {
+ $row[] = '-';
+ }
+
+ switch ($order) {
+ case 'total':
+ $row['sort'] = $total;
+ break;
+ case 'oldest-all':
+ $row['sort'] = $oldest_all;
+ break;
+ case 'oldest-pri':
+ $row['sort'] = $oldest_pri;
+ break;
+ case 'closed':
+ $row['sort'] = count($closed);
+ break;
+ case 'name':
+ default:
+ $row['sort'] = $handle ? $handle->getName() : '~';
+ break;
+ }
+
+ $rows[] = $row;
+ }
+
+ $rows = isort($rows, 'sort');
+ foreach ($rows as $k => $row) {
+ unset($rows[$k]['sort']);
+ }
+ if ($reverse) {
+ $rows = array_reverse($rows);
+ }
+
+ $cname = array($col_header);
+ $cclass = array('pri left narrow');
+ $pri_map = ManiphestTaskPriority::getShortNameMap();
+ foreach ($pri_map as $pri => $label) {
+ $cname[] = $label;
+ $cclass[] = 'center narrow';
+ }
+ $cname[] = 'Total';
+ $cclass[] = 'center narrow';
+ $cname[] = javelin_tag(
+ 'span',
+ array(
+ 'sigil' => 'has-tooltip',
+ 'meta' => array(
+ 'tip' => pht('Oldest open task.'),
+ 'size' => 200,
+ ),
+ ),
+ pht('Oldest (All)'));
+ $cclass[] = 'center narrow';
+ $cname[] = javelin_tag(
+ 'span',
+ array(
+ 'sigil' => 'has-tooltip',
+ 'meta' => array(
+ 'tip' => pht('Oldest open task, excluding those with Low or '.
+ 'Wishlist priority.'),
+ 'size' => 200,
+ ),
+ ),
+ pht('Oldest (Pri)'));
+ $cclass[] = 'center narrow';
+
+ list($ignored, $window_epoch) = $this->getWindow();
+ $edate = phabricator_datetime($window_epoch, $user);
+ $cname[] = javelin_tag(
+ 'span',
+ array(
+ 'sigil' => 'has-tooltip',
+ 'meta' => array(
+ 'tip' => pht('Closed after %s', $edate),
+ 'size' => 260
+ ),
+ ),
+ pht('Recently Closed'));
+ $cclass[] = 'center narrow';
+
+ $table = new AphrontTableView($rows);
+ $table->setHeaders($cname);
+ $table->setColumnClasses($cclass);
+ $table->makeSortable(
+ $request->getRequestURI(),
+ 'order',
+ $order,
+ $reverse,
+ array(
+ 'name',
+ null,
+ null,
+ null,
+ null,
+ null,
+ null,
+ 'total',
+ 'oldest-all',
+ 'oldest-pri',
+ 'closed',
+ ));
+
+ $panel = new PHUIObjectBoxView();
+ $panel->setHeaderText($header);
+ $panel->appendChild($table);
+
+ $tokens = array();
+ if ($project_handle) {
+ $tokens = array($project_handle);
+ }
+ $filter = $this->renderReportFilters($tokens, $has_window = true);
+
+ return array($filter, $panel);
+ }
+
+
+ /**
+ * Load all the tasks that have been recently closed.
+ */
+ private function loadRecentlyClosedTasks() {
+ list($ignored, $window_epoch) = $this->getWindow();
+
+ $table = new ManiphestTask();
+ $xtable = new ManiphestTransaction();
+ $conn_r = $table->establishConnection('r');
+
+ // TODO: Gross. This table is not meant to be queried like this. Build
+ // real stats tables.
+
+ $open_status_list = array();
+ foreach (ManiphestTaskStatus::getOpenStatusConstants() as $constant) {
+ $open_status_list[] = json_encode((string)$constant);
+ }
+
+ $rows = queryfx_all(
+ $conn_r,
+ 'SELECT t.id FROM %T t JOIN %T x ON x.objectPHID = t.phid
+ WHERE t.status NOT IN (%Ls)
+ AND x.oldValue IN (null, %Ls)
+ AND x.newValue NOT IN (%Ls)
+ AND t.dateModified >= %d
+ AND x.dateCreated >= %d',
+ $table->getTableName(),
+ $xtable->getTableName(),
+ ManiphestTaskStatus::getOpenStatusConstants(),
+ $open_status_list,
+ $open_status_list,
+ $window_epoch,
+ $window_epoch);
+
+ if (!$rows) {
+ return array();
+ }
+
+ $ids = ipull($rows, 'id');
+
+ return id(new ManiphestTaskQuery())
+ ->setViewer($this->getRequest()->getUser())
+ ->withIDs($ids)
+ ->execute();
+ }
+
+ /**
+ * Parse the "Recently Means" filter into:
+ *
+ * - A string representation, like "12 AM 7 days ago" (default);
+ * - a locale-aware epoch representation; and
+ * - a possible error.
+ */
+ private function getWindow() {
+ $request = $this->getRequest();
+ $user = $request->getUser();
+
+ $window_str = $this->getRequest()->getStr('window', '12 AM 7 days ago');
+
+ $error = null;
+ $window_epoch = null;
+
+ // Do locale-aware parsing so that the user's timezone is assumed for
+ // time windows like "3 PM", rather than assuming the server timezone.
+
+ $window_epoch = PhabricatorTime::parseLocalTime($window_str, $user);
+ if (!$window_epoch) {
+ $error = 'Invalid';
+ $window_epoch = time() - (60 * 60 * 24 * 7);
+ }
+
+ // If the time ends up in the future, convert it to the corresponding time
+ // and equal distance in the past. This is so users can type "6 days"
(which
+ // means "6 days from now") and get the behavior of "6 days ago", rather
+ // than no results (because the window epoch is in the future). This might
+ // be a little confusing because it casues "tomorrow" to mean "yesterday"
+ // and "2022" (or whatever) to mean "ten years ago", but these inputs are
+ // nonsense anyway.
+
+ if ($window_epoch > time()) {
+ $window_epoch = time() - ($window_epoch - time());
+ }
+
+ return array($window_str, $window_epoch, $error);
+ }
+
+ private function renderOldest(array $tasks) {
+ assert_instances_of($tasks, 'ManiphestTask');
+ $oldest = null;
+ foreach ($tasks as $id => $task) {
+ if (($oldest === null) ||
+ ($task->getDateCreated() < $tasks[$oldest]->getDateCreated())) {
+ $oldest = $id;
+ }
+ }
+
+ if ($oldest === null) {
+ return array('-', 0);
+ }
+
+ $oldest = $tasks[$oldest];
+
+ $raw_age = (time() - $oldest->getDateCreated());
+ $age = number_format($raw_age / (24 * 60 * 60)).' d';
+
+ $link = javelin_tag(
+ 'a',
+ array(
+ 'href' => '/T'.$oldest->getID(),
+ 'sigil' => 'has-tooltip',
+ 'meta' => array(
+ 'tip' => 'T'.$oldest->getID().': '.$oldest->getTitle(),
+ ),
+ 'target' => '_blank',
+ ),
+ $age);
+
+ return array($link, $raw_age);
+ }
+
+}
+
--
To view, visit https://gerrit.wikimedia.org/r/165220
To unsubscribe, visit https://gerrit.wikimedia.org/r/settings
Gerrit-MessageType: merged
Gerrit-Change-Id: I9148971fb50a407cb843efd046817855a2365e9e
Gerrit-PatchSet: 3
Gerrit-Project: phabricator/extensions/Sprint
Gerrit-Branch: master
Gerrit-Owner: Christopher Johnson (WMDE) <[email protected]>
Gerrit-Reviewer: Christopher Johnson (WMDE) <[email protected]>
_______________________________________________
MediaWiki-commits mailing list
[email protected]
https://lists.wikimedia.org/mailman/listinfo/mediawiki-commits