Path: blob/master/src/applications/maniphest/controller/ManiphestReportController.php
12256 views
<?php12final class ManiphestReportController extends ManiphestController {34private $view;56public function handleRequest(AphrontRequest $request) {7$viewer = $this->getViewer();8$this->view = $request->getURIData('view');910if ($request->isFormPost()) {11$uri = $request->getRequestURI();1213$project = head($request->getArr('set_project'));14$project = nonempty($project, null);1516if ($project !== null) {17$uri->replaceQueryParam('project', $project);18} else {19$uri->removeQueryParam('project');20}2122$window = $request->getStr('set_window');23if ($window !== null) {24$uri->replaceQueryParam('window', $window);25} else {26$uri->removeQueryParam('window');27}2829return id(new AphrontRedirectResponse())->setURI($uri);30}3132$nav = new AphrontSideNavFilterView();33$nav->setBaseURI(new PhutilURI('/maniphest/report/'));34$nav->addLabel(pht('Open Tasks'));35$nav->addFilter('user', pht('By User'));36$nav->addFilter('project', pht('By Project'));3738$class = 'PhabricatorFactApplication';39if (PhabricatorApplication::isClassInstalledForViewer($class, $viewer)) {40$nav->addLabel(pht('Burnup'));41$nav->addFilter('burn', pht('Burnup Rate'));42}4344$this->view = $nav->selectFilter($this->view, 'user');4546require_celerity_resource('maniphest-report-css');4748switch ($this->view) {49case 'burn':50$core = $this->renderBurn();51break;52case 'user':53case 'project':54$core = $this->renderOpenTasks();55break;56default:57return new Aphront404Response();58}5960$crumbs = $this->buildApplicationCrumbs()61->addTextCrumb(pht('Reports'));6263$nav->appendChild($core);64$title = pht('Maniphest Reports');6566return $this->newPage()67->setTitle($title)68->setCrumbs($crumbs)69->setNavigation($nav);7071}7273public function renderBurn() {74$request = $this->getRequest();75$viewer = $request->getUser();7677$handle = null;7879$project_phid = $request->getStr('project');80if ($project_phid) {81$phids = array($project_phid);82$handles = $this->loadViewerHandles($phids);83$handle = $handles[$project_phid];84}8586$table = new ManiphestTransaction();87$conn = $table->establishConnection('r');8889if ($project_phid) {90$joins = qsprintf(91$conn,92'JOIN %T t ON x.objectPHID = t.phid93JOIN %T p ON p.src = t.phid AND p.type = %d AND p.dst = %s',94id(new ManiphestTask())->getTableName(),95PhabricatorEdgeConfig::TABLE_NAME_EDGE,96PhabricatorProjectObjectHasProjectEdgeType::EDGECONST,97$project_phid);98$create_joins = qsprintf(99$conn,100'JOIN %T p ON p.src = t.phid AND p.type = %d AND p.dst = %s',101PhabricatorEdgeConfig::TABLE_NAME_EDGE,102PhabricatorProjectObjectHasProjectEdgeType::EDGECONST,103$project_phid);104} else {105$joins = qsprintf($conn, '');106$create_joins = qsprintf($conn, '');107}108109$data = queryfx_all(110$conn,111'SELECT x.transactionType, x.oldValue, x.newValue, x.dateCreated112FROM %T x %Q113WHERE transactionType IN (%Ls)114ORDER BY x.dateCreated ASC',115$table->getTableName(),116$joins,117array(118ManiphestTaskStatusTransaction::TRANSACTIONTYPE,119ManiphestTaskMergedIntoTransaction::TRANSACTIONTYPE,120));121122// See PHI273. After the move to EditEngine, we no longer create a123// "status" transaction if a task is created directly into the default124// status. This likely impacted API/email tasks after 2016 and all other125// tasks after late 2017. Until Facts can fix this properly, use the126// task creation dates to generate synthetic transactions which look like127// the older transactions that this page expects.128129$default_status = ManiphestTaskStatus::getDefaultStatus();130$duplicate_status = ManiphestTaskStatus::getDuplicateStatus();131132// Build synthetic transactions which take status from `null` to the133// default value.134$create_rows = queryfx_all(135$conn,136'SELECT t.dateCreated FROM %T t %Q',137id(new ManiphestTask())->getTableName(),138$create_joins);139foreach ($create_rows as $key => $create_row) {140$create_rows[$key] = array(141'transactionType' => 'status',142'oldValue' => null,143'newValue' => $default_status,144'dateCreated' => $create_row['dateCreated'],145);146}147148// Remove any actual legacy status transactions which take status from149// `null` to any open status.150foreach ($data as $key => $row) {151if ($row['transactionType'] != 'status') {152continue;153}154155$oldv = trim($row['oldValue'], '"');156$newv = trim($row['newValue'], '"');157158// If this is a status change, preserve it.159if ($oldv != 'null') {160continue;161}162163// If this task was created directly into a closed status, preserve164// the transaction.165if (!ManiphestTaskStatus::isOpenStatus($newv)) {166continue;167}168169// If this is a legacy "create" transaction, discard it in favor of the170// synthetic one.171unset($data[$key]);172}173174// Merge the synthetic rows into the real transactions.175$data = array_merge($create_rows, $data);176$data = array_values($data);177$data = isort($data, 'dateCreated');178179$stats = array();180$day_buckets = array();181182$open_tasks = array();183184foreach ($data as $key => $row) {185switch ($row['transactionType']) {186case ManiphestTaskStatusTransaction::TRANSACTIONTYPE:187// NOTE: Hack to avoid json_decode().188$oldv = trim($row['oldValue'], '"');189$newv = trim($row['newValue'], '"');190break;191case ManiphestTaskMergedIntoTransaction::TRANSACTIONTYPE:192// NOTE: Merging a task does not generate a "status" transaction.193// We pretend it did. Note that this is not always accurate: it is194// possible to merge a task which was previously closed, but this195// fake transaction always counts a merge as a closure.196$oldv = $default_status;197$newv = $duplicate_status;198break;199}200201if ($oldv == 'null') {202$old_is_open = false;203} else {204$old_is_open = ManiphestTaskStatus::isOpenStatus($oldv);205}206207$new_is_open = ManiphestTaskStatus::isOpenStatus($newv);208209$is_open = ($new_is_open && !$old_is_open);210$is_close = ($old_is_open && !$new_is_open);211212$data[$key]['_is_open'] = $is_open;213$data[$key]['_is_close'] = $is_close;214215if (!$is_open && !$is_close) {216// This is either some kind of bogus event, or a resolution change217// (e.g., resolved -> invalid). Just skip it.218continue;219}220221$day_bucket = phabricator_format_local_time(222$row['dateCreated'],223$viewer,224'Yz');225$day_buckets[$day_bucket] = $row['dateCreated'];226if (empty($stats[$day_bucket])) {227$stats[$day_bucket] = array(228'open' => 0,229'close' => 0,230);231}232$stats[$day_bucket][$is_close ? 'close' : 'open']++;233}234235$template = array(236'open' => 0,237'close' => 0,238);239240$rows = array();241$rowc = array();242$last_month = null;243$last_month_epoch = null;244$last_week = null;245$last_week_epoch = null;246$week = null;247$month = null;248249$last = last_key($stats) - 1;250$period = $template;251252foreach ($stats as $bucket => $info) {253$epoch = $day_buckets[$bucket];254255$week_bucket = phabricator_format_local_time(256$epoch,257$viewer,258'YW');259if ($week_bucket != $last_week) {260if ($week) {261$rows[] = $this->formatBurnRow(262pht('Week of %s', phabricator_date($last_week_epoch, $viewer)),263$week);264$rowc[] = 'week';265}266$week = $template;267$last_week = $week_bucket;268$last_week_epoch = $epoch;269}270271$month_bucket = phabricator_format_local_time(272$epoch,273$viewer,274'Ym');275if ($month_bucket != $last_month) {276if ($month) {277$rows[] = $this->formatBurnRow(278phabricator_format_local_time($last_month_epoch, $viewer, 'F, Y'),279$month);280$rowc[] = 'month';281}282$month = $template;283$last_month = $month_bucket;284$last_month_epoch = $epoch;285}286287$rows[] = $this->formatBurnRow(phabricator_date($epoch, $viewer), $info);288$rowc[] = null;289$week['open'] += $info['open'];290$week['close'] += $info['close'];291$month['open'] += $info['open'];292$month['close'] += $info['close'];293$period['open'] += $info['open'];294$period['close'] += $info['close'];295}296297if ($week) {298$rows[] = $this->formatBurnRow(299pht('Week To Date'),300$week);301$rowc[] = 'week';302}303304if ($month) {305$rows[] = $this->formatBurnRow(306pht('Month To Date'),307$month);308$rowc[] = 'month';309}310311$rows[] = $this->formatBurnRow(312pht('All Time'),313$period);314$rowc[] = 'aggregate';315316$rows = array_reverse($rows);317$rowc = array_reverse($rowc);318319$table = new AphrontTableView($rows);320$table->setRowClasses($rowc);321$table->setHeaders(322array(323pht('Period'),324pht('Opened'),325pht('Closed'),326pht('Change'),327));328$table->setColumnClasses(329array(330'right wide',331'n',332'n',333'n',334));335336if ($handle) {337$inst = pht(338'NOTE: This table reflects tasks currently in '.339'the project. If a task was opened in the past but added to '.340'the project recently, it is counted on the day it was '.341'opened, not the day it was categorized. If a task was part '.342'of this project in the past but no longer is, it is not '.343'counted at all. This table may not agree exactly with the chart '.344'above.');345$header = pht('Task Burn Rate for Project %s', $handle->renderLink());346$caption = phutil_tag('p', array(), $inst);347} else {348$header = pht('Task Burn Rate for All Tasks');349$caption = null;350}351352if ($caption) {353$caption = id(new PHUIInfoView())354->appendChild($caption)355->setSeverity(PHUIInfoView::SEVERITY_NOTICE);356}357358$panel = new PHUIObjectBoxView();359$panel->setHeaderText($header);360if ($caption) {361$panel->setInfoView($caption);362}363$panel->setTable($table);364365$tokens = array();366if ($handle) {367$tokens = array($handle);368}369370$filter = $this->renderReportFilters($tokens, $has_window = false);371372$id = celerity_generate_unique_node_id();373$chart = phutil_tag(374'div',375array(376'id' => $id,377'style' => 'border: 1px solid #BFCFDA; '.378'background-color: #fff; '.379'margin: 8px 16px; '.380'height: 400px; ',381),382'');383384list($burn_x, $burn_y) = $this->buildSeries($data);385386if ($project_phid) {387$projects = id(new PhabricatorProjectQuery())388->setViewer($viewer)389->withPHIDs(array($project_phid))390->execute();391} else {392$projects = array();393}394395$panel = id(new PhabricatorProjectBurndownChartEngine())396->setViewer($viewer)397->setProjects($projects)398->buildChartPanel();399400$panel->setName(pht('Burnup Rate'));401402$chart_view = id(new PhabricatorDashboardPanelRenderingEngine())403->setViewer($viewer)404->setPanel($panel)405->setParentPanelPHIDs(array())406->renderPanel();407408return array($filter, $chart_view);409}410411private function renderReportFilters(array $tokens, $has_window) {412$request = $this->getRequest();413$viewer = $request->getUser();414415$form = id(new AphrontFormView())416->setUser($viewer)417->appendControl(418id(new AphrontFormTokenizerControl())419->setDatasource(new PhabricatorProjectDatasource())420->setLabel(pht('Project'))421->setLimit(1)422->setName('set_project')423// TODO: This is silly, but this is Maniphest reports.424->setValue(mpull($tokens, 'getPHID')));425426if ($has_window) {427list($window_str, $ignored, $window_error) = $this->getWindow();428$form429->appendChild(430id(new AphrontFormTextControl())431->setLabel(pht('Recently Means'))432->setName('set_window')433->setCaption(434pht('Configure the cutoff for the "Recently Closed" column.'))435->setValue($window_str)436->setError($window_error));437}438439$form440->appendChild(441id(new AphrontFormSubmitControl())442->setValue(pht('Filter By Project')));443444$filter = new AphrontListFilterView();445$filter->appendChild($form);446447return $filter;448}449450private function buildSeries(array $data) {451$out = array();452453$counter = 0;454foreach ($data as $row) {455$t = (int)$row['dateCreated'];456if ($row['_is_close']) {457--$counter;458$out[$t] = $counter;459} else if ($row['_is_open']) {460++$counter;461$out[$t] = $counter;462}463}464465return array(array_keys($out), array_values($out));466}467468private function formatBurnRow($label, $info) {469$delta = $info['open'] - $info['close'];470$fmt = number_format($delta);471if ($delta > 0) {472$fmt = '+'.$fmt;473$fmt = phutil_tag('span', array('class' => 'red'), $fmt);474} else {475$fmt = phutil_tag('span', array('class' => 'green'), $fmt);476}477478return array(479$label,480number_format($info['open']),481number_format($info['close']),482$fmt,483);484}485486public function renderOpenTasks() {487$request = $this->getRequest();488$viewer = $request->getUser();489490491$query = id(new ManiphestTaskQuery())492->setViewer($viewer)493->withStatuses(ManiphestTaskStatus::getOpenStatusConstants());494495switch ($this->view) {496case 'project':497$query->needProjectPHIDs(true);498break;499}500501$project_phid = $request->getStr('project');502$project_handle = null;503if ($project_phid) {504$phids = array($project_phid);505$handles = $this->loadViewerHandles($phids);506$project_handle = $handles[$project_phid];507508$query->withEdgeLogicPHIDs(509PhabricatorProjectObjectHasProjectEdgeType::EDGECONST,510PhabricatorQueryConstraint::OPERATOR_OR,511$phids);512}513514$tasks = $query->execute();515516$recently_closed = $this->loadRecentlyClosedTasks();517518$date = phabricator_date(time(), $viewer);519520switch ($this->view) {521case 'user':522$result = mgroup($tasks, 'getOwnerPHID');523$leftover = idx($result, '', array());524unset($result['']);525526$result_closed = mgroup($recently_closed, 'getOwnerPHID');527$leftover_closed = idx($result_closed, '', array());528unset($result_closed['']);529530$base_link = '/maniphest/?assigned=';531$leftover_name = phutil_tag('em', array(), pht('(Up For Grabs)'));532$col_header = pht('User');533$header = pht('Open Tasks by User and Priority (%s)', $date);534break;535case 'project':536$result = array();537$leftover = array();538foreach ($tasks as $task) {539$phids = $task->getProjectPHIDs();540if ($phids) {541foreach ($phids as $project_phid) {542$result[$project_phid][] = $task;543}544} else {545$leftover[] = $task;546}547}548549$result_closed = array();550$leftover_closed = array();551foreach ($recently_closed as $task) {552$phids = $task->getProjectPHIDs();553if ($phids) {554foreach ($phids as $project_phid) {555$result_closed[$project_phid][] = $task;556}557} else {558$leftover_closed[] = $task;559}560}561562$base_link = '/maniphest/?projects=';563$leftover_name = phutil_tag('em', array(), pht('(No Project)'));564$col_header = pht('Project');565$header = pht('Open Tasks by Project and Priority (%s)', $date);566break;567}568569$phids = array_keys($result);570$handles = $this->loadViewerHandles($phids);571$handles = msort($handles, 'getName');572573$order = $request->getStr('order', 'name');574list($order, $reverse) = AphrontTableView::parseSort($order);575576require_celerity_resource('aphront-tooltip-css');577Javelin::initBehavior('phabricator-tooltips', array());578579$rows = array();580$pri_total = array();581foreach (array_merge($handles, array(null)) as $handle) {582if ($handle) {583if (($project_handle) &&584($project_handle->getPHID() == $handle->getPHID())) {585// If filtering by, e.g., "bugs", don't show a "bugs" group.586continue;587}588589$tasks = idx($result, $handle->getPHID(), array());590$name = phutil_tag(591'a',592array(593'href' => $base_link.$handle->getPHID(),594),595$handle->getName());596$closed = idx($result_closed, $handle->getPHID(), array());597} else {598$tasks = $leftover;599$name = $leftover_name;600$closed = $leftover_closed;601}602603$taskv = $tasks;604$tasks = mgroup($tasks, 'getPriority');605606$row = array();607$row[] = $name;608$total = 0;609foreach (ManiphestTaskPriority::getTaskPriorityMap() as $pri => $label) {610$n = count(idx($tasks, $pri, array()));611if ($n == 0) {612$row[] = '-';613} else {614$row[] = number_format($n);615}616$total += $n;617}618$row[] = number_format($total);619620list($link, $oldest_all) = $this->renderOldest($taskv);621$row[] = $link;622623$normal_or_better = array();624foreach ($taskv as $id => $task) {625// TODO: This is sort of a hard-code for the default "normal" status.626// When reports are more powerful, this should be made more general.627if ($task->getPriority() < 50) {628continue;629}630$normal_or_better[$id] = $task;631}632633list($link, $oldest_pri) = $this->renderOldest($normal_or_better);634$row[] = $link;635636if ($closed) {637$task_ids = implode(',', mpull($closed, 'getID'));638$row[] = phutil_tag(639'a',640array(641'href' => '/maniphest/?ids='.$task_ids,642'target' => '_blank',643),644number_format(count($closed)));645} else {646$row[] = '-';647}648649switch ($order) {650case 'total':651$row['sort'] = $total;652break;653case 'oldest-all':654$row['sort'] = $oldest_all;655break;656case 'oldest-pri':657$row['sort'] = $oldest_pri;658break;659case 'closed':660$row['sort'] = count($closed);661break;662case 'name':663default:664$row['sort'] = $handle ? $handle->getName() : '~';665break;666}667668$rows[] = $row;669}670671$rows = isort($rows, 'sort');672foreach ($rows as $k => $row) {673unset($rows[$k]['sort']);674}675if ($reverse) {676$rows = array_reverse($rows);677}678679$cname = array($col_header);680$cclass = array('pri right wide');681$pri_map = ManiphestTaskPriority::getShortNameMap();682foreach ($pri_map as $pri => $label) {683$cname[] = $label;684$cclass[] = 'n';685}686$cname[] = pht('Total');687$cclass[] = 'n';688$cname[] = javelin_tag(689'span',690array(691'sigil' => 'has-tooltip',692'meta' => array(693'tip' => pht('Oldest open task.'),694'size' => 200,695),696),697pht('Oldest (All)'));698$cclass[] = 'n';699$cname[] = javelin_tag(700'span',701array(702'sigil' => 'has-tooltip',703'meta' => array(704'tip' => pht(705'Oldest open task, excluding those with Low or Wishlist priority.'),706'size' => 200,707),708),709pht('Oldest (Pri)'));710$cclass[] = 'n';711712list($ignored, $window_epoch) = $this->getWindow();713$edate = phabricator_datetime($window_epoch, $viewer);714$cname[] = javelin_tag(715'span',716array(717'sigil' => 'has-tooltip',718'meta' => array(719'tip' => pht('Closed after %s', $edate),720'size' => 260,721),722),723pht('Recently Closed'));724$cclass[] = 'n';725726$table = new AphrontTableView($rows);727$table->setHeaders($cname);728$table->setColumnClasses($cclass);729$table->makeSortable(730$request->getRequestURI(),731'order',732$order,733$reverse,734array(735'name',736null,737null,738null,739null,740null,741null,742'total',743'oldest-all',744'oldest-pri',745'closed',746));747748$panel = new PHUIObjectBoxView();749$panel->setHeaderText($header);750$panel->setTable($table);751752$tokens = array();753if ($project_handle) {754$tokens = array($project_handle);755}756$filter = $this->renderReportFilters($tokens, $has_window = true);757758return array($filter, $panel);759}760761762/**763* Load all the tasks that have been recently closed.764*/765private function loadRecentlyClosedTasks() {766list($ignored, $window_epoch) = $this->getWindow();767768$table = new ManiphestTask();769$xtable = new ManiphestTransaction();770$conn_r = $table->establishConnection('r');771772// TODO: Gross. This table is not meant to be queried like this. Build773// real stats tables.774775$open_status_list = array();776foreach (ManiphestTaskStatus::getOpenStatusConstants() as $constant) {777$open_status_list[] = json_encode((string)$constant);778}779780$rows = queryfx_all(781$conn_r,782'SELECT t.id FROM %T t JOIN %T x ON x.objectPHID = t.phid783WHERE t.status NOT IN (%Ls)784AND x.oldValue IN (null, %Ls)785AND x.newValue NOT IN (%Ls)786AND t.dateModified >= %d787AND x.dateCreated >= %d',788$table->getTableName(),789$xtable->getTableName(),790ManiphestTaskStatus::getOpenStatusConstants(),791$open_status_list,792$open_status_list,793$window_epoch,794$window_epoch);795796if (!$rows) {797return array();798}799800$ids = ipull($rows, 'id');801802$query = id(new ManiphestTaskQuery())803->setViewer($this->getRequest()->getUser())804->withIDs($ids);805806switch ($this->view) {807case 'project':808$query->needProjectPHIDs(true);809break;810}811812return $query->execute();813}814815/**816* Parse the "Recently Means" filter into:817*818* - A string representation, like "12 AM 7 days ago" (default);819* - a locale-aware epoch representation; and820* - a possible error.821*/822private function getWindow() {823$request = $this->getRequest();824$viewer = $request->getUser();825826$window_str = $this->getRequest()->getStr('window', '12 AM 7 days ago');827828$error = null;829$window_epoch = null;830831// Do locale-aware parsing so that the user's timezone is assumed for832// time windows like "3 PM", rather than assuming the server timezone.833834$window_epoch = PhabricatorTime::parseLocalTime($window_str, $viewer);835if (!$window_epoch) {836$error = 'Invalid';837$window_epoch = time() - (60 * 60 * 24 * 7);838}839840// If the time ends up in the future, convert it to the corresponding time841// and equal distance in the past. This is so users can type "6 days" (which842// means "6 days from now") and get the behavior of "6 days ago", rather843// than no results (because the window epoch is in the future). This might844// be a little confusing because it causes "tomorrow" to mean "yesterday"845// and "2022" (or whatever) to mean "ten years ago", but these inputs are846// nonsense anyway.847848if ($window_epoch > time()) {849$window_epoch = time() - ($window_epoch - time());850}851852return array($window_str, $window_epoch, $error);853}854855private function renderOldest(array $tasks) {856assert_instances_of($tasks, 'ManiphestTask');857$oldest = null;858foreach ($tasks as $id => $task) {859if (($oldest === null) ||860($task->getDateCreated() < $tasks[$oldest]->getDateCreated())) {861$oldest = $id;862}863}864865if ($oldest === null) {866return array('-', 0);867}868869$oldest = $tasks[$oldest];870871$raw_age = (time() - $oldest->getDateCreated());872$age = number_format($raw_age / (24 * 60 * 60)).' d';873874$link = javelin_tag(875'a',876array(877'href' => '/T'.$oldest->getID(),878'sigil' => 'has-tooltip',879'meta' => array(880'tip' => 'T'.$oldest->getID().': '.$oldest->getTitle(),881),882'target' => '_blank',883),884$age);885886return array($link, $raw_age);887}888889}890891892