Page MenuHomestyx hydra

No OneTemporary

diff --git a/src/applications/fact/chart/PhabricatorChartDataset.php b/src/applications/fact/chart/PhabricatorChartDataset.php
index 3251808188..48355c3b36 100644
--- a/src/applications/fact/chart/PhabricatorChartDataset.php
+++ b/src/applications/fact/chart/PhabricatorChartDataset.php
@@ -1,37 +1,42 @@
<?php
final class PhabricatorChartDataset
extends Phobject {
private $function;
public function getFunction() {
return $this->function;
}
+ public function setFunction(PhabricatorComposeChartFunction $function) {
+ $this->function = $function;
+ return $this;
+ }
+
public static function newFromDictionary(array $map) {
PhutilTypeSpec::checkMap(
$map,
array(
'function' => 'list<wild>',
));
$dataset = new self();
$dataset->function = id(new PhabricatorComposeChartFunction())
->setArguments(array($map['function']));
return $dataset;
}
public function toDictionary() {
// Since we wrap the raw value in a "compose(...)", when deserializing,
// we need to unwrap it when serializing.
$function_raw = head($this->getFunction()->toDictionary());
return array(
'function' => $function_raw,
);
}
}
diff --git a/src/applications/fact/chart/PhabricatorChartFunctionArgument.php b/src/applications/fact/chart/PhabricatorChartFunctionArgument.php
index bbcf8209ba..91786dc9da 100644
--- a/src/applications/fact/chart/PhabricatorChartFunctionArgument.php
+++ b/src/applications/fact/chart/PhabricatorChartFunctionArgument.php
@@ -1,139 +1,144 @@
<?php
final class PhabricatorChartFunctionArgument
extends Phobject {
private $name;
private $type;
private $repeatable;
public function setName($name) {
$this->name = $name;
return $this;
}
public function getName() {
return $this->name;
}
public function setRepeatable($repeatable) {
$this->repeatable = $repeatable;
return $this;
}
public function getRepeatable() {
return $this->repeatable;
}
public function setType($type) {
$types = array(
'fact-key' => true,
'function' => true,
'number' => true,
+ 'phid' => true,
);
if (!isset($types[$type])) {
throw new Exception(
pht(
'Chart function argument type "%s" is unknown. Valid types '.
'are: %s.',
$type,
implode(', ', array_keys($types))));
}
$this->type = $type;
return $this;
}
public function getType() {
return $this->type;
}
public function newValue($value) {
switch ($this->getType()) {
+ case 'phid':
+ // TODO: This could be validated better, but probably should not be
+ // a primitive type.
+ return $value;
case 'fact-key':
if (!is_string($value)) {
throw new Exception(
pht(
'Value for "fact-key" argument must be a string, got %s.',
phutil_describe_type($value)));
}
$facts = PhabricatorFact::getAllFacts();
$fact = idx($facts, $value);
if (!$fact) {
throw new Exception(
pht(
'Fact key "%s" is not a known fact key.',
$value));
}
return $fact;
case 'function':
// If this is already a function object, just return it.
if ($value instanceof PhabricatorChartFunction) {
return $value;
}
if (!is_array($value)) {
throw new Exception(
pht(
'Value for "function" argument must be a function definition, '.
'formatted as a list, like: [fn, arg1, arg, ...]. Actual value '.
'is %s.',
phutil_describe_type($value)));
}
if (!phutil_is_natural_list($value)) {
throw new Exception(
pht(
'Value for "function" argument must be a natural list, not '.
'a dictionary. Actual value is "%s".',
phutil_describe_type($value)));
}
if (!$value) {
throw new Exception(
pht(
'Value for "function" argument must be a list with a function '.
'name; got an empty list.'));
}
$function_name = array_shift($value);
if (!is_string($function_name)) {
throw new Exception(
pht(
'Value for "function" argument must be a natural list '.
'beginning with a function name as a string. The first list '.
'item has the wrong type, %s.',
phutil_describe_type($function_name)));
}
$functions = PhabricatorChartFunction::getAllFunctions();
if (!isset($functions[$function_name])) {
throw new Exception(
pht(
'Function "%s" is unknown. Valid functions are: %s',
$function_name,
implode(', ', array_keys($functions))));
}
return id(clone $functions[$function_name])
->setArguments($value);
case 'number':
if (!is_float($value) && !is_int($value)) {
throw new Exception(
pht(
'Value for "number" argument must be an integer or double, '.
'got %s.',
phutil_describe_type($value)));
}
return $value;
}
throw new PhutilMethodNotImplementedException();
}
}
diff --git a/src/applications/fact/chart/PhabricatorFactChartFunction.php b/src/applications/fact/chart/PhabricatorFactChartFunction.php
index ea59d3459e..ae2ba52472 100644
--- a/src/applications/fact/chart/PhabricatorFactChartFunction.php
+++ b/src/applications/fact/chart/PhabricatorFactChartFunction.php
@@ -1,89 +1,102 @@
<?php
final class PhabricatorFactChartFunction
extends PhabricatorChartFunction {
const FUNCTIONKEY = 'fact';
private $fact;
private $map;
protected function newArguments() {
$key_argument = $this->newArgument()
->setName('fact-key')
->setType('fact-key');
$parser = $this->getArgumentParser();
$parser->parseArgument($key_argument);
$fact = $this->getArgument('fact-key');
$this->fact = $fact;
return $fact->getFunctionArguments();
}
public function loadData() {
$fact = $this->fact;
$key_id = id(new PhabricatorFactKeyDimension())
->newDimensionID($fact->getKey());
if (!$key_id) {
return;
}
$table = $fact->newDatapoint();
$conn = $table->establishConnection('r');
$table_name = $table->getTableName();
- $data = queryfx_all(
+ $where = array();
+
+ $where[] = qsprintf(
$conn,
- 'SELECT value, epoch FROM %T WHERE keyID = %d ORDER BY epoch ASC',
- $table_name,
+ 'keyID = %d',
$key_id);
- if (!$data) {
- return;
+
+ $parser = $this->getArgumentParser();
+
+ $parts = $fact->buildWhereClauseParts($conn, $parser);
+ foreach ($parts as $part) {
+ $where[] = $part;
}
+ $data = queryfx_all(
+ $conn,
+ 'SELECT value, epoch FROM %T WHERE %LA ORDER BY epoch ASC',
+ $table_name,
+ $where);
+
$map = array();
- foreach ($data as $row) {
- $value = (int)$row['value'];
- $epoch = (int)$row['epoch'];
+ if ($data) {
+ foreach ($data as $row) {
+ $value = (int)$row['value'];
+ $epoch = (int)$row['epoch'];
- if (!isset($map[$epoch])) {
- $map[$epoch] = 0;
- }
+ if (!isset($map[$epoch])) {
+ $map[$epoch] = 0;
+ }
- $map[$epoch] += $value;
+ $map[$epoch] += $value;
+ }
}
$this->map = $map;
}
public function getDomain() {
return array(
head_key($this->map),
last_key($this->map),
);
}
public function newInputValues(PhabricatorChartDataQuery $query) {
return array_keys($this->map);
}
public function evaluateFunction(array $xv) {
$map = $this->map;
$yv = array();
foreach ($xv as $x) {
if (isset($map[$x])) {
$yv[] = $map[$x];
} else {
$yv[] = null;
}
}
return $yv;
}
}
diff --git a/src/applications/fact/fact/PhabricatorFact.php b/src/applications/fact/fact/PhabricatorFact.php
index a52fe5435e..a30f34fa56 100644
--- a/src/applications/fact/fact/PhabricatorFact.php
+++ b/src/applications/fact/fact/PhabricatorFact.php
@@ -1,44 +1,83 @@
<?php
abstract class PhabricatorFact extends Phobject {
private $key;
public static function getAllFacts() {
$engines = PhabricatorFactEngine::loadAllEngines();
$map = array();
foreach ($engines as $engine) {
$facts = $engine->newFacts();
$facts = mpull($facts, null, 'getKey');
$map += $facts;
}
return $map;
}
final public function setKey($key) {
$this->key = $key;
return $this;
}
final public function getKey() {
return $this->key;
}
final public function getName() {
return pht('Fact "%s"', $this->getKey());
}
final public function newDatapoint() {
return $this->newTemplateDatapoint()
->setKey($this->getKey());
}
abstract protected function newTemplateDatapoint();
final public function getFunctionArguments() {
- return array();
+ $key = $this->getKey();
+
+ $argv = array();
+
+ if (preg_match('/\.project\z/', $key)) {
+ $argv[] = id(new PhabricatorChartFunctionArgument())
+ ->setName('phid')
+ ->setType('phid');
+ }
+
+ if (preg_match('/\.owner\z/', $key)) {
+ $argv[] = id(new PhabricatorChartFunctionArgument())
+ ->setName('phid')
+ ->setType('phid');
+ }
+
+ return $argv;
}
+ final public function buildWhereClauseParts(
+ AphrontDatabaseConnection $conn,
+ PhabricatorChartFunctionArgumentParser $arguments) {
+ $where = array();
+
+ $has_phid = $this->getFunctionArguments();
+
+ if ($has_phid) {
+ $phid = $arguments->getArgumentValue('phid');
+
+ $dimension_id = id(new PhabricatorFactObjectDimension())
+ ->newDimensionID($phid);
+
+ $where[] = qsprintf(
+ $conn,
+ 'dimensionID = %d',
+ $dimension_id);
+ }
+
+ return $where;
+ }
+
+
}
diff --git a/src/applications/maniphest/controller/ManiphestReportController.php b/src/applications/maniphest/controller/ManiphestReportController.php
index 90210a0ee4..4e5c57c76e 100644
--- a/src/applications/maniphest/controller/ManiphestReportController.php
+++ b/src/applications/maniphest/controller/ManiphestReportController.php
@@ -1,882 +1,919 @@
<?php
final class ManiphestReportController extends ManiphestController {
private $view;
public function handleRequest(AphrontRequest $request) {
$viewer = $this->getViewer();
$this->view = $request->getURIData('view');
if ($request->isFormPost()) {
$uri = $request->getRequestURI();
$project = head($request->getArr('set_project'));
$project = nonempty($project, null);
if ($project !== null) {
$uri->replaceQueryParam('project', $project);
} else {
$uri->removeQueryParam('project');
}
$window = $request->getStr('set_window');
if ($window !== null) {
$uri->replaceQueryParam('window', $window);
} else {
$uri->removeQueryParam('window');
}
return id(new AphrontRedirectResponse())->setURI($uri);
}
$nav = new AphrontSideNavFilterView();
$nav->setBaseURI(new PhutilURI('/maniphest/report/'));
$nav->addLabel(pht('Open Tasks'));
$nav->addFilter('user', pht('By User'));
$nav->addFilter('project', pht('By Project'));
$nav->addLabel(pht('Burnup'));
$nav->addFilter('burn', pht('Burnup 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();
}
$crumbs = $this->buildApplicationCrumbs()
->addTextCrumb(pht('Reports'));
$nav->appendChild($core);
$title = pht('Maniphest Reports');
return $this->newPage()
->setTitle($title)
->setCrumbs($crumbs)
->setNavigation($nav);
}
public function renderBurn() {
$request = $this->getRequest();
$viewer = $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');
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);
$create_joins = qsprintf(
$conn,
'JOIN %T p ON p.src = t.phid AND p.type = %d AND p.dst = %s',
PhabricatorEdgeConfig::TABLE_NAME_EDGE,
PhabricatorProjectObjectHasProjectEdgeType::EDGECONST,
$project_phid);
} else {
$joins = qsprintf($conn, '');
$create_joins = qsprintf($conn, '');
}
$data = queryfx_all(
$conn,
'SELECT x.transactionType, x.oldValue, x.newValue, x.dateCreated
FROM %T x %Q
WHERE transactionType IN (%Ls)
ORDER BY x.dateCreated ASC',
$table->getTableName(),
$joins,
array(
ManiphestTaskStatusTransaction::TRANSACTIONTYPE,
ManiphestTaskMergedIntoTransaction::TRANSACTIONTYPE,
));
// See PHI273. After the move to EditEngine, we no longer create a
// "status" transaction if a task is created directly into the default
// status. This likely impacted API/email tasks after 2016 and all other
// tasks after late 2017. Until Facts can fix this properly, use the
// task creation dates to generate synthetic transactions which look like
// the older transactions that this page expects.
$default_status = ManiphestTaskStatus::getDefaultStatus();
$duplicate_status = ManiphestTaskStatus::getDuplicateStatus();
// Build synthetic transactions which take status from `null` to the
// default value.
$create_rows = queryfx_all(
$conn,
'SELECT t.dateCreated FROM %T t %Q',
id(new ManiphestTask())->getTableName(),
$create_joins);
foreach ($create_rows as $key => $create_row) {
$create_rows[$key] = array(
'transactionType' => 'status',
'oldValue' => null,
'newValue' => $default_status,
'dateCreated' => $create_row['dateCreated'],
);
}
// Remove any actual legacy status transactions which take status from
// `null` to any open status.
foreach ($data as $key => $row) {
if ($row['transactionType'] != 'status') {
continue;
}
$oldv = trim($row['oldValue'], '"');
$newv = trim($row['newValue'], '"');
// If this is a status change, preserve it.
if ($oldv != 'null') {
continue;
}
// If this task was created directly into a closed status, preserve
// the transaction.
if (!ManiphestTaskStatus::isOpenStatus($newv)) {
continue;
}
// If this is a legacy "create" transaction, discard it in favor of the
// synthetic one.
unset($data[$key]);
}
// Merge the synthetic rows into the real transactions.
$data = array_merge($create_rows, $data);
$data = array_values($data);
$data = isort($data, 'dateCreated');
$stats = array();
$day_buckets = array();
$open_tasks = array();
foreach ($data as $key => $row) {
switch ($row['transactionType']) {
case ManiphestTaskStatusTransaction::TRANSACTIONTYPE:
// NOTE: Hack to avoid json_decode().
$oldv = trim($row['oldValue'], '"');
$newv = trim($row['newValue'], '"');
break;
case ManiphestTaskMergedIntoTransaction::TRANSACTIONTYPE:
// NOTE: Merging a task does not generate a "status" transaction.
// We pretend it did. Note that this is not always accurate: it is
// possible to merge a task which was previously closed, but this
// fake transaction always counts a merge as a closure.
$oldv = $default_status;
$newv = $duplicate_status;
break;
}
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'],
$viewer,
'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,
$viewer,
'YW');
if ($week_bucket != $last_week) {
if ($week) {
$rows[] = $this->formatBurnRow(
pht('Week of %s', phabricator_date($last_week_epoch, $viewer)),
$week);
$rowc[] = 'week';
}
$week = $template;
$last_week = $week_bucket;
$last_week_epoch = $epoch;
}
$month_bucket = phabricator_format_local_time(
$epoch,
$viewer,
'Ym');
if ($month_bucket != $last_month) {
if ($month) {
$rows[] = $this->formatBurnRow(
phabricator_format_local_time($last_month_epoch, $viewer, 'F, Y'),
$month);
$rowc[] = 'month';
}
$month = $template;
$last_month = $month_bucket;
$last_month_epoch = $epoch;
}
$rows[] = $this->formatBurnRow(phabricator_date($epoch, $viewer), $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(
'right wide',
'n',
'n',
'n',
));
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.');
+ 'counted at all. This table may not agree exactly with the chart '.
+ 'above.');
$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 PHUIInfoView())
->appendChild($caption)
->setSeverity(PHUIInfoView::SEVERITY_NOTICE);
}
$panel = new PHUIObjectBoxView();
$panel->setHeaderText($header);
if ($caption) {
$panel->setInfoView($caption);
}
$panel->setTable($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('d3');
- require_celerity_resource('phui-chart-css');
+ if ($project_phid) {
+ $argv = array(
+ 'sum',
+ array(
+ 'accumulate',
+ array('fact', 'tasks.open-count.create.project', $project_phid),
+ ),
+ array(
+ 'accumulate',
+ array('fact', 'tasks.open-count.status.project', $project_phid),
+ ),
+ array(
+ 'accumulate',
+ array('fact', 'tasks.open-count.assign.project', $project_phid),
+ ),
+ );
+ } else {
+ $argv = array(
+ 'sum',
+ array('accumulate', array('fact', 'tasks.open-count.create')),
+ array('accumulate', array('fact', 'tasks.open-count.status')),
+ );
+ }
- Javelin::initBehavior('line-chart-legacy', array(
- 'hardpoint' => $id,
- 'x' => array(
- $burn_x,
- ),
- 'y' => array(
- $burn_y,
- ),
- 'xformat' => 'epoch',
- 'yformat' => 'int',
- ));
+ $function = id(new PhabricatorComposeChartFunction())
+ ->setArguments(array($argv));
- $box = id(new PHUIObjectBoxView())
- ->setHeaderText(pht('Burnup Rate'))
- ->appendChild($chart);
+ $datasets = array(
+ id(new PhabricatorChartDataset())
+ ->setFunction($function),
+ );
+
+ $chart = id(new PhabricatorFactChart())
+ ->setDatasets($datasets);
+
+ $engine = id(new PhabricatorChartEngine())
+ ->setViewer($viewer)
+ ->setChart($chart);
+
+ $chart = $engine->getStoredChart();
+
+ $panel_type = id(new PhabricatorDashboardChartPanelType())
+ ->getPanelTypeKey();
+
+ $chart_panel = id(new PhabricatorDashboardPanel())
+ ->setPanelType($panel_type)
+ ->setName(pht('Burnup Rate'))
+ ->setProperty('chartKey', $chart->getChartKey());
+
+ $chart_view = id(new PhabricatorDashboardPanelRenderingEngine())
+ ->setViewer($viewer)
+ ->setPanel($chart_panel)
+ ->setParentPanelPHIDs(array())
+ ->renderPanel();
- return array($filter, $box, $panel);
+ return array($filter, $chart_view, $panel);
}
private function renderReportFilters(array $tokens, $has_window) {
$request = $this->getRequest();
$viewer = $request->getUser();
$form = id(new AphrontFormView())
->setUser($viewer)
->appendControl(
id(new AphrontFormTokenizerControl())
->setDatasource(new PhabricatorProjectDatasource())
->setLabel(pht('Project'))
->setLimit(1)
->setName('set_project')
// TODO: This is silly, but this is Maniphest reports.
->setValue(mpull($tokens, 'getPHID')));
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();
$viewer = $request->getUser();
$query = id(new ManiphestTaskQuery())
->setViewer($viewer)
->withStatuses(ManiphestTaskStatus::getOpenStatusConstants());
switch ($this->view) {
case 'project':
$query->needProjectPHIDs(true);
break;
}
$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->withEdgeLogicPHIDs(
PhabricatorProjectObjectHasProjectEdgeType::EDGECONST,
PhabricatorQueryConstraint::OPERATOR_OR,
$phids);
}
$tasks = $query->execute();
$recently_closed = $this->loadRecentlyClosedTasks();
$date = phabricator_date(time(), $viewer);
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/?projects=';
$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 right wide');
$pri_map = ManiphestTaskPriority::getShortNameMap();
foreach ($pri_map as $pri => $label) {
$cname[] = $label;
$cclass[] = 'n';
}
$cname[] = pht('Total');
$cclass[] = 'n';
$cname[] = javelin_tag(
'span',
array(
'sigil' => 'has-tooltip',
'meta' => array(
'tip' => pht('Oldest open task.'),
'size' => 200,
),
),
pht('Oldest (All)'));
$cclass[] = 'n';
$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[] = 'n';
list($ignored, $window_epoch) = $this->getWindow();
$edate = phabricator_datetime($window_epoch, $viewer);
$cname[] = javelin_tag(
'span',
array(
'sigil' => 'has-tooltip',
'meta' => array(
'tip' => pht('Closed after %s', $edate),
'size' => 260,
),
),
pht('Recently Closed'));
$cclass[] = 'n';
$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->setTable($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');
$query = id(new ManiphestTaskQuery())
->setViewer($this->getRequest()->getUser())
->withIDs($ids);
switch ($this->view) {
case 'project':
$query->needProjectPHIDs(true);
break;
}
return $query->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();
$viewer = $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, $viewer);
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 causes "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);
}
}

File Metadata

Mime Type
text/x-diff
Expires
Tue, Jun 10, 9:54 PM (1 d, 6 h)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
141134
Default Alt Text
(37 KB)

Event Timeline