@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<?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}