@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.) hq.recaptime.dev/wiki/Phorge
phorge phabricator
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

at recaptime-dev/main 612 lines 17 kB view raw
1<?php 2 3final class ManiphestReportController extends ManiphestController { 4 5 private $view; 6 7 public function handleRequest(AphrontRequest $request) { 8 $viewer = $this->getViewer(); 9 $this->view = $request->getURIData('view'); 10 11 if ($request->isFormPost()) { 12 $uri = $request->getRequestURI(); 13 14 $project = head($request->getArr('set_project')); 15 $project = nonempty($project, null); 16 17 if ($project !== null) { 18 $uri->replaceQueryParam('project', $project); 19 } else { 20 $uri->removeQueryParam('project'); 21 } 22 23 $window = $request->getStr('set_window'); 24 if ($window !== null) { 25 $uri->replaceQueryParam('window', $window); 26 } else { 27 $uri->removeQueryParam('window'); 28 } 29 30 return id(new AphrontRedirectResponse())->setURI($uri); 31 } 32 33 $nav = new AphrontSideNavFilterView(); 34 $nav->setBaseURI(new PhutilURI('/maniphest/report/')); 35 $nav->addLabel(pht('Open Tasks')); 36 $nav->addFilter('user', pht('By User')); 37 $nav->addFilter('project', pht('By Project')); 38 39 $class = PhabricatorFactApplication::class; 40 if (PhabricatorApplication::isClassInstalledForViewer($class, $viewer)) { 41 $nav->addLabel(pht('Burnup / Burndown')); 42 $nav->addFilter('burn', pht('Burnup / Burndown Rate')); 43 } 44 45 $this->view = $nav->selectFilter($this->view, 'user'); 46 47 require_celerity_resource('maniphest-report-css'); 48 49 switch ($this->view) { 50 case 'burn': 51 $core = $this->renderBurn(); 52 break; 53 case 'user': 54 case 'project': 55 $core = $this->renderOpenTasks(); 56 break; 57 default: 58 return new Aphront404Response(); 59 } 60 61 $crumbs = $this->buildApplicationCrumbs() 62 ->addTextCrumb(pht('Reports')); 63 64 $nav->appendChild($core); 65 $title = pht('Maniphest Reports'); 66 67 return $this->newPage() 68 ->setTitle($title) 69 ->setCrumbs($crumbs) 70 ->setNavigation($nav); 71 72 } 73 74 /** 75 * Render the 'Burnup / Burndown Rate' on /maniphest/report/burn/. 76 * 77 * The same thing on /project/reports/$id/ is handled by 78 * PhabricatorProjectReportsController instead. 79 * 80 * @return array<AphrontListFilterView, PHUIObjectBoxView> 81 */ 82 public function renderBurn() { 83 $request = $this->getRequest(); 84 $viewer = $request->getUser(); 85 86 $handle = null; 87 88 $project_phid = $request->getStr('project'); 89 if ($project_phid) { 90 $phids = array($project_phid); 91 $handles = $this->loadViewerHandles($phids); 92 $handle = $handles[$project_phid]; 93 } 94 95 $tokens = array(); 96 if ($handle) { 97 $tokens = array($handle); 98 } 99 100 $filter = $this->renderReportFilters($tokens, $has_window = false); 101 102 if ($project_phid) { 103 $projects = id(new PhabricatorProjectQuery()) 104 ->setViewer($viewer) 105 ->withPHIDs(array($project_phid)) 106 ->execute(); 107 } else { 108 $projects = array(); 109 } 110 111 $panel = id(new PhabricatorProjectBurndownChartEngine()) 112 ->setViewer($viewer) 113 ->setProjects($projects) 114 ->buildChartPanel(); 115 116 $panel->setName(pht('Burnup / Burndown Rate')); 117 118 $chart_view = id(new PhabricatorDashboardPanelRenderingEngine()) 119 ->setViewer($viewer) 120 ->setPanel($panel) 121 ->setParentPanelPHIDs(array()) 122 ->renderPanel(); 123 124 return array($filter, $chart_view); 125 } 126 127 /** 128 * @param array $tokens 129 * @param bool $has_window 130 * @return AphrontListFilterView 131 */ 132 private function renderReportFilters(array $tokens, $has_window) { 133 $request = $this->getRequest(); 134 $viewer = $request->getUser(); 135 136 $form = id(new AphrontFormView()) 137 ->setUser($viewer) 138 ->appendControl( 139 id(new AphrontFormTokenizerControl()) 140 ->setDatasource(new PhabricatorProjectDatasource()) 141 ->setLabel(pht('Project')) 142 ->setLimit(1) 143 ->setName('set_project') 144 // TODO: This is silly, but this is Maniphest reports. 145 ->setValue(mpull($tokens, 'getPHID'))); 146 147 if ($has_window) { 148 list($window_str, $ignored, $window_error) = $this->getWindow(); 149 $form 150 ->appendChild( 151 id(new AphrontFormTextControl()) 152 ->setLabel(pht('Recently Means')) 153 ->setName('set_window') 154 ->setCaption( 155 pht('Configure the cutoff for the "Recently Closed" column.')) 156 ->setValue($window_str) 157 ->setError($window_error)); 158 } 159 160 $form 161 ->appendChild( 162 id(new AphrontFormSubmitControl()) 163 ->setValue(pht('Filter By Project'))); 164 165 $filter = new AphrontListFilterView(); 166 $filter->appendChild($form); 167 168 return $filter; 169 } 170 171 /** 172 * @return int 50, the default value of the default "normal" Priority 173 */ 174 private function getAveragePriority() { 175 // TODO: This is sort of a hard-code for the default "normal" status. 176 // When reports are more powerful, this should be made more general. 177 return 50; 178 } 179 180 /** 181 * Render all table cells in the "Open Tasks" table on /maniphest/report/*. 182 * 183 * @return array<AphrontListFilterView,PHUIObjectBoxView> 184 */ 185 public function renderOpenTasks() { 186 $request = $this->getRequest(); 187 $viewer = $request->getUser(); 188 189 190 $query = id(new ManiphestTaskQuery()) 191 ->setViewer($viewer) 192 ->withStatuses(ManiphestTaskStatus::getOpenStatusConstants()); 193 194 switch ($this->view) { 195 case 'project': 196 $query->needProjectPHIDs(true); 197 break; 198 } 199 200 $project_phid = $request->getStr('project'); 201 $project_handle = null; 202 if ($project_phid) { 203 $phids = array($project_phid); 204 $handles = $this->loadViewerHandles($phids); 205 $project_handle = $handles[$project_phid]; 206 207 $query->withEdgeLogicPHIDs( 208 PhabricatorProjectObjectHasProjectEdgeType::EDGECONST, 209 PhabricatorQueryConstraint::OPERATOR_OR, 210 $phids); 211 } 212 213 $tasks = $query->execute(); 214 215 $recently_closed = $this->loadRecentlyClosedTasks(); 216 217 $date = phabricator_date(time(), $viewer); 218 219 switch ($this->view) { 220 case 'user': 221 $result = mgroup($tasks, 'getOwnerPHID'); 222 $leftover = idx($result, '', array()); 223 unset($result['']); 224 225 $result_closed = mgroup($recently_closed, 'getOwnerPHID'); 226 $leftover_closed = idx($result_closed, '', array()); 227 unset($result_closed['']); 228 229 $base_link = '/maniphest/?assigned='; 230 $leftover_name = phutil_tag('em', array(), pht('(Up For Grabs)')); 231 $col_header = pht('User'); 232 $header = pht('Open Tasks by User and Priority (%s)', $date); 233 break; 234 case 'project': 235 $result = array(); 236 $leftover = array(); 237 foreach ($tasks as $task) { 238 $phids = $task->getProjectPHIDs(); 239 if ($phids) { 240 foreach ($phids as $project_phid) { 241 $result[$project_phid][] = $task; 242 } 243 } else { 244 $leftover[] = $task; 245 } 246 } 247 248 $result_closed = array(); 249 $leftover_closed = array(); 250 foreach ($recently_closed as $task) { 251 $phids = $task->getProjectPHIDs(); 252 if ($phids) { 253 foreach ($phids as $project_phid) { 254 $result_closed[$project_phid][] = $task; 255 } 256 } else { 257 $leftover_closed[] = $task; 258 } 259 } 260 261 $base_link = '/maniphest/?projects='; 262 $leftover_name = phutil_tag('em', array(), pht('(No Project)')); 263 $col_header = pht('Project'); 264 $header = pht('Open Tasks by Project and Priority (%s)', $date); 265 break; 266 } 267 268 $phids = array_keys($result); 269 $handles = $this->loadViewerHandles($phids); 270 $handles = msort($handles, 'getName'); 271 272 $order = $request->getStr('order', 'name'); 273 list($order, $reverse) = AphrontTableView::parseSort($order); 274 275 require_celerity_resource('aphront-tooltip-css'); 276 Javelin::initBehavior('phabricator-tooltips', array()); 277 278 $rows = array(); 279 $pri_total = array(); 280 foreach (array_merge($handles, array(null)) as $handle) { 281 if ($handle) { 282 if (($project_handle) && 283 ($project_handle->getPHID() == $handle->getPHID())) { 284 // If filtering by, e.g., "bugs", don't show a "bugs" group. 285 continue; 286 } 287 288 $tasks = idx($result, $handle->getPHID(), array()); 289 $name = phutil_tag( 290 'a', 291 array( 292 'href' => $base_link.$handle->getPHID(), 293 ), 294 $handle->getName()); 295 $closed = idx($result_closed, $handle->getPHID(), array()); 296 } else { 297 $tasks = $leftover; 298 $name = $leftover_name; 299 $closed = $leftover_closed; 300 } 301 302 $taskv = $tasks; 303 $tasks = mgroup($tasks, 'getPriority'); 304 305 $row = array(); 306 $row[] = $name; 307 $total = 0; 308 foreach (ManiphestTaskPriority::getTaskPriorityMap() as $pri => $label) { 309 $n = count(idx($tasks, $pri, array())); 310 if ($n == 0) { 311 $row[] = '-'; 312 } else { 313 $row[] = number_format($n); 314 } 315 $total += $n; 316 } 317 $row[] = number_format($total); 318 319 list($link, $oldest_all) = $this->renderOldest($taskv); 320 $row[] = $link; 321 322 $normal_or_better = array(); 323 foreach ($taskv as $id => $task) { 324 if ($task->getPriority() < $this->getAveragePriority()) { 325 continue; 326 } 327 $normal_or_better[$id] = $task; 328 } 329 330 list($link, $oldest_pri) = $this->renderOldest($normal_or_better); 331 $row[] = $link; 332 333 if ($closed) { 334 $task_ids = implode(',', mpull($closed, 'getID')); 335 $row[] = phutil_tag( 336 'a', 337 array( 338 'href' => '/maniphest/?ids='.$task_ids, 339 'target' => '_blank', 340 ), 341 number_format(count($closed))); 342 } else { 343 $row[] = '-'; 344 } 345 346 switch ($order) { 347 case 'total': 348 $row['sort'] = $total; 349 break; 350 case 'oldest-all': 351 $row['sort'] = $oldest_all; 352 break; 353 case 'oldest-pri': 354 $row['sort'] = $oldest_pri; 355 break; 356 case 'closed': 357 $row['sort'] = count($closed); 358 break; 359 case 'name': 360 default: 361 $row['sort'] = $handle ? $handle->getName() : '~'; 362 break; 363 } 364 365 $rows[] = $row; 366 } 367 368 $rows = isort($rows, 'sort'); 369 foreach ($rows as $k => $row) { 370 unset($rows[$k]['sort']); 371 } 372 if ($reverse) { 373 $rows = array_reverse($rows); 374 } 375 376 $cname = array($col_header); 377 $cclass = array('pri right wide'); 378 $pri_map = ManiphestTaskPriority::getShortNameMap(); 379 foreach ($pri_map as $pri => $label) { 380 $cname[] = $label; 381 $cclass[] = 'n'; 382 } 383 $cname[] = pht('Total'); 384 $cclass[] = 'n'; 385 $cname[] = javelin_tag( 386 'span', 387 array( 388 'sigil' => 'has-tooltip', 389 'meta' => array( 390 'tip' => pht('Oldest open task.'), 391 'size' => 200, 392 ), 393 ), 394 pht('Oldest (All)')); 395 $cclass[] = 'n'; 396 $low_priorities = array(); 397 $priorities_map = ManiphestTaskPriority::getTaskPriorityMap(); 398 $normal_priority = $this->getAveragePriority(); 399 foreach ($priorities_map as $pri => $full_label) { 400 if ($pri < $normal_priority) { 401 $low_priorities[] = $full_label; 402 } 403 } 404 $pri_string = implode(', ', $low_priorities); 405 $cname[] = javelin_tag( 406 'span', 407 array( 408 'sigil' => 'has-tooltip', 409 'meta' => array( 410 'tip' => pht( 411 'Oldest open task, excluding those with priority %s', $pri_string), 412 'size' => 200, 413 ), 414 ), 415 pht('Oldest (Pri)')); 416 $cclass[] = 'n'; 417 418 list($ignored, $window_epoch) = $this->getWindow(); 419 $edate = phabricator_datetime($window_epoch, $viewer); 420 $cname[] = javelin_tag( 421 'span', 422 array( 423 'sigil' => 'has-tooltip', 424 'meta' => array( 425 'tip' => pht('Closed after %s', $edate), 426 'size' => 260, 427 ), 428 ), 429 pht('Recently Closed')); 430 $cclass[] = 'n'; 431 432 $table = new AphrontTableView($rows); 433 $table->setHeaders($cname); 434 $table->setColumnClasses($cclass); 435 $table->makeSortable( 436 $request->getRequestURI(), 437 'order', 438 $order, 439 $reverse, 440 array( 441 'name', 442 null, 443 null, 444 null, 445 null, 446 null, 447 null, 448 'total', 449 'oldest-all', 450 'oldest-pri', 451 'closed', 452 )); 453 454 $panel = new PHUIObjectBoxView(); 455 $panel->setHeaderText($header); 456 $panel->setTable($table); 457 458 $tokens = array(); 459 if ($project_handle) { 460 $tokens = array($project_handle); 461 } 462 $filter = $this->renderReportFilters($tokens, $has_window = true); 463 464 return array($filter, $panel); 465 } 466 467 468 /** 469 * Load all tasks that have been recently closed. 470 * This is used for the "Recently Closed" column on /maniphest/report/*. 471 * 472 * @return array<ManiphestTask|null> 473 */ 474 private function loadRecentlyClosedTasks() { 475 list($ignored, $window_epoch) = $this->getWindow(); 476 477 $table = new ManiphestTask(); 478 $xtable = new ManiphestTransaction(); 479 $conn_r = $table->establishConnection('r'); 480 481 // TODO: Gross. This table is not meant to be queried like this. Build 482 // real stats tables. 483 484 $open_status_list = array(); 485 foreach (ManiphestTaskStatus::getOpenStatusConstants() as $constant) { 486 $open_status_list[] = json_encode((string)$constant); 487 } 488 489 $rows = queryfx_all( 490 $conn_r, 491 'SELECT t.id FROM %T t JOIN %T x ON x.objectPHID = t.phid 492 WHERE t.status NOT IN (%Ls) 493 AND x.oldValue IN (null, %Ls) 494 AND x.newValue NOT IN (%Ls) 495 AND t.dateModified >= %d 496 AND x.dateCreated >= %d', 497 $table->getTableName(), 498 $xtable->getTableName(), 499 ManiphestTaskStatus::getOpenStatusConstants(), 500 $open_status_list, 501 $open_status_list, 502 $window_epoch, 503 $window_epoch); 504 505 if (!$rows) { 506 return array(); 507 } 508 509 $ids = ipull($rows, 'id'); 510 511 $query = id(new ManiphestTaskQuery()) 512 ->setViewer($this->getRequest()->getUser()) 513 ->withIDs($ids); 514 515 switch ($this->view) { 516 case 'project': 517 $query->needProjectPHIDs(true); 518 break; 519 } 520 521 return $query->execute(); 522 } 523 524 /** 525 * Parse the "Recently Means" filter on /maniphest/report/* into: 526 * - A string representation, like "12 AM 7 days ago" (default); 527 * - a locale-aware epoch representation; and 528 * - a possible error. 529 * This is used for the "Recently Closed" column on /maniphest/report/*. 530 * 531 * @return array Array with three items: "Recently Means" user input; 532 * Resulting epoch timeframe used to get "Recently Closed" numbers 533 * (when user input is invalid, it defaults to a week ago); "Invalid" 534 * if first parameter could not be parsed as an epoch, else null. 535 * array<string,integer,string|null> 536 */ 537 private function getWindow() { 538 $request = $this->getRequest(); 539 $viewer = $request->getUser(); 540 541 $window_str = $this->getRequest()->getStr('window', '12 AM 7 days ago'); 542 543 $error = null; 544 $window_epoch = null; 545 546 // Do locale-aware parsing so that the user's timezone is assumed for 547 // time windows like "3 PM", rather than assuming the server timezone. 548 549 $window_epoch = PhabricatorTime::parseLocalTime($window_str, $viewer); 550 if (!$window_epoch) { 551 $error = 'Invalid'; 552 $window_epoch = time() - (60 * 60 * 24 * 7); 553 } 554 555 // If the time ends up in the future, convert it to the corresponding time 556 // and equal distance in the past. This is so users can type "6 days" (which 557 // means "6 days from now") and get the behavior of "6 days ago", rather 558 // than no results (because the window epoch is in the future). This might 559 // be a little confusing because it causes "tomorrow" to mean "yesterday" 560 // and "2022" (or whatever) to mean "ten years ago", but these inputs are 561 // nonsense anyway. 562 563 if ($window_epoch > time()) { 564 $window_epoch = time() - ($window_epoch - time()); 565 } 566 567 return array($window_str, $window_epoch, $error); 568 } 569 570 /** 571 * Render date of oldest open task per user or per project with a link. 572 * Used on /maniphest/report/user/ and /maniphest/report/project/ URIs. 573 * 574 * @param array<ManiphestTask> $tasks 575 * @return array<PhutilSafeHTML,int> HTML link markup and the timespan 576 * (as epoch) since task creation 577 */ 578 private function renderOldest(array $tasks) { 579 assert_instances_of($tasks, ManiphestTask::class); 580 $oldest = null; 581 foreach ($tasks as $id => $task) { 582 if (($oldest === null) || 583 ($task->getDateCreated() < $tasks[$oldest]->getDateCreated())) { 584 $oldest = $id; 585 } 586 } 587 588 if ($oldest === null) { 589 return array('-', 0); 590 } 591 592 $oldest = $tasks[$oldest]; 593 594 $raw_age = (time() - $oldest->getDateCreated()); 595 $age = number_format($raw_age / (24 * 60 * 60)).' d'; 596 597 $link = javelin_tag( 598 'a', 599 array( 600 'href' => '/T'.$oldest->getID(), 601 'sigil' => 'has-tooltip', 602 'meta' => array( 603 'tip' => 'T'.$oldest->getID().': '.$oldest->getTitle(), 604 ), 605 'target' => '_blank', 606 ), 607 $age); 608 609 return array($link, $raw_age); 610 } 611 612}