diff --git a/resources/sql/autopatches/20140321.mstatus.1.col.sql b/resources/sql/autopatches/20140321.mstatus.1.col.sql new file mode 100644 index 0000000000..df407e0908 --- /dev/null +++ b/resources/sql/autopatches/20140321.mstatus.1.col.sql @@ -0,0 +1,2 @@ +ALTER TABLE {$NAMESPACE}_maniphest.maniphest_task + CHANGE status status VARCHAR(12) NOT NULL COLLATE latin1_bin; diff --git a/resources/sql/autopatches/20140321.mstatus.2.mig.php b/resources/sql/autopatches/20140321.mstatus.2.mig.php new file mode 100644 index 0000000000..66c13844c3 --- /dev/null +++ b/resources/sql/autopatches/20140321.mstatus.2.mig.php @@ -0,0 +1,94 @@ + 'open', + 1 => 'resolved', + 2 => 'wontfix', + 3 => 'invalid', + 4 => 'duplicate', + 5 => 'spite', +); + +$conn_w = id(new ManiphestTask())->establishConnection('w'); + +echo "Migrating tasks to new status constants...\n"; +foreach (new LiskMigrationIterator(new ManiphestTask()) as $task) { + $id = $task->getID(); + echo "Migrating T{$id}...\n"; + + $status = $task->getStatus(); + if (isset($status_map[$status])) { + queryfx( + $conn_w, + 'UPDATE %T SET status = %s WHERE id = %d', + $task->getTableName(), + $status_map[$status], + $id); + } +} + +echo "Done.\n"; + + +echo "Migrating task transactions to new status constants...\n"; +foreach (new LiskMigrationIterator(new ManiphestTransaction()) as $xaction) { + $id = $xaction->getID(); + echo "Migrating {$id}...\n"; + + if ($xaction->getTransactionType() == ManiphestTransaction::TYPE_STATUS) { + $old = $xaction->getOldValue(); + if ($old !== null && isset($status_map[$old])) { + $old = $status_map[$old]; + } + + $new = $xaction->getNewValue(); + if (isset($status_map[$new])) { + $new = $status_map[$new]; + } + + queryfx( + $conn_w, + 'UPDATE %T SET oldValue = %s, newValue = %s WHERE id = %d', + $xaction->getTableName(), + json_encode($old), + json_encode($new), + $id); + } +} +echo "Done.\n"; + +$conn_w = id(new PhabricatorSavedQuery())->establishConnection('w'); + +echo "Migrating searches to new status constants...\n"; +foreach (new LiskMigrationIterator(new PhabricatorSavedQuery()) as $query) { + $id = $query->getID(); + echo "Migrating {$id}...\n"; + + if ($query->getEngineClassName() !== 'ManiphestTaskSearchEngine') { + continue; + } + + $params = $query->getParameters(); + $statuses = idx($params, 'statuses', array()); + if ($statuses) { + $changed = false; + foreach ($statuses as $key => $status) { + if (isset($status_map[$status])) { + $statuses[$key] = $status_map[$status]; + $changed = true; + } + } + + if ($changed) { + $params['statuses'] = $statuses; + + queryfx( + $conn_w, + 'UPDATE %T SET parameters = %s WHERE id = %d', + $query->getTableName(), + json_encode($params), + $id); + } + } +} +echo "Done.\n"; diff --git a/src/applications/maniphest/constants/ManiphestTaskStatus.php b/src/applications/maniphest/constants/ManiphestTaskStatus.php index 276af1ec00..1d8ccf91f9 100644 --- a/src/applications/maniphest/constants/ManiphestTaskStatus.php +++ b/src/applications/maniphest/constants/ManiphestTaskStatus.php @@ -1,254 +1,254 @@ array( 'name' => pht('Open'), 'special' => self::SPECIAL_DEFAULT, ), self::STATUS_CLOSED_RESOLVED => array( 'name' => pht('Resolved'), 'name.full' => pht('Closed, Resolved'), 'closed' => true, 'special' => self::SPECIAL_CLOSED, 'prefixes' => array( 'closed', 'closes', 'close', 'fix', 'fixes', 'fixed', 'resolve', 'resolves', 'resolved', ), 'suffixes' => array( 'as resolved', 'as fixed', ), ), self::STATUS_CLOSED_WONTFIX => array( 'name' => pht('Wontfix'), 'name.full' => pht('Closed, Wontfix'), 'closed' => true, 'prefixes' => array( 'wontfix', 'wontfixes', 'wontfixed', ), 'suffixes' => array( 'as wontfix', ), ), self::STATUS_CLOSED_INVALID => array( 'name' => pht('Invalid'), 'name.full' => pht('Closed, Invalid'), 'closed' => true, 'prefixes' => array( 'invalidate', 'invalidates', 'invalidated', ), 'suffixes' => array( 'as invalid', ), ), self::STATUS_CLOSED_DUPLICATE => array( 'name' => pht('Duplicate'), 'name.full' => pht('Closed, Duplicate'), 'transaction.icon' => 'delete', 'special' => self::SPECIAL_DUPLICATE, 'closed' => true, ), self::STATUS_CLOSED_SPITE => array( 'name' => pht('Spite'), 'name.full' => pht('Closed, Spite'), 'name.action' => pht('Spited'), 'transaction.icon' => 'dislike', 'silly' => true, 'closed' => true, 'prefixes' => array( 'spite', 'spites', 'spited', ), 'suffixes' => array( 'out of spite', 'as spite', ), ), ); } private static function getEnabledStatusMap() { $spec = self::getStatusConfig(); $is_serious = PhabricatorEnv::getEnvConfig('phabricator.serious-business'); foreach ($spec as $const => $status) { if ($is_serious && !empty($status['silly'])) { unset($spec[$const]); continue; } } return $spec; } public static function getTaskStatusMap() { return ipull(self::getEnabledStatusMap(), 'name'); } public static function getTaskStatusName($status) { return self::getStatusAttribute($status, 'name', pht('Unknown Status')); } public static function getTaskStatusFullName($status) { $name = self::getStatusAttribute($status, 'name.full'); if ($name !== null) { return $name; } return self::getStatusAttribute($status, 'name', pht('Unknown Status')); } public static function renderFullDescription($status) { if (self::isOpenStatus($status)) { $color = 'status'; $icon = 'oh-open'; } else { $color = 'status-dark'; $icon = 'oh-closed-dark'; } $img = id(new PHUIIconView()) ->setSpriteSheet(PHUIIconView::SPRITE_STATUS) ->setSpriteIcon($icon); $tag = phutil_tag( 'span', array( 'class' => 'phui-header-'.$color.' plr', ), array( $img, self::getTaskStatusFullName($status), )); return $tag; } private static function getSpecialStatus($special) { foreach (self::getEnabledStatusMap() as $const => $status) { if (idx($status, 'special') == $special) { return $const; } } return null; } public static function getDefaultStatus() { return self::getSpecialStatus(self::SPECIAL_DEFAULT); } public static function getDefaultClosedStatus() { return self::getSpecialStatus(self::SPECIAL_CLOSED); } public static function getDuplicateStatus() { return self::getSpecialStatus(self::SPECIAL_DUPLICATE); } public static function getOpenStatusConstants() { $result = array(); foreach (self::getEnabledStatusMap() as $const => $status) { if (empty($status['closed'])) { $result[] = $const; } } return $result; } public static function getClosedStatusConstants() { $all = array_keys(self::getTaskStatusMap()); $open = self::getOpenStatusConstants(); return array_diff($all, $open); } public static function isOpenStatus($status) { foreach (self::getOpenStatusConstants() as $constant) { if ($status == $constant) { return true; } } return false; } public static function isClosedStatus($status) { return !self::isOpenStatus($status); } public static function getStatusActionName($status) { return self::getStatusAttribute($status, 'name.action'); } public static function getStatusColor($status) { return self::getStatusAttribute($status, 'transaction.color'); } public static function getStatusIcon($status) { return self::getStatusAttribute($status, 'transaction.icon'); } public static function getStatusPrefixMap() { $map = array(); foreach (self::getEnabledStatusMap() as $const => $status) { foreach (idx($status, 'prefixes', array()) as $prefix) { $map[$prefix] = $const; } } $map += array( 'ref' => null, 'refs' => null, 'references' => null, 'cf.' => null, ); return $map; } public static function getStatusSuffixMap() { $map = array(); foreach (self::getEnabledStatusMap() as $const => $status) { foreach (idx($status, 'suffixes', array()) as $prefix) { $map[$prefix] = $const; } } return $map; } private static function getStatusAttribute($status, $key, $default = null) { $config = self::getStatusConfig(); $spec = idx($config, $status); if ($spec) { return idx($spec, $key, $default); } return $default; } } diff --git a/src/applications/maniphest/controller/ManiphestReportController.php b/src/applications/maniphest/controller/ManiphestReportController.php index 2f56718400..0777e32dd1 100644 --- a/src/applications/maniphest/controller/ManiphestReportController.php +++ b/src/applications/maniphest/controller/ManiphestReportController.php @@ -1,759 +1,758 @@ 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('/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(); } $nav->appendChild($core); $nav->setCrumbs( $this->buildApplicationCrumbs() ->addTextCrumb(pht('Reports'))); return $this->buildApplicationPage( $nav, array( 'title' => pht('Maniphest Reports'), )); } 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.taskPHID = t.phid AND p.projectPHID = %s', id(new ManiphestTask())->getTableName(), id(new ManiphestTaskProject())->getTableName(), $project_phid); } $data = queryfx_all( $conn, 'SELECT 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( '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."); $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; } $panel = new AphrontPanelView(); $panel->setHeader($header); $panel->setCaption($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 #6f6f6f; '. 'margin: 1em 2em; '. '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('/typeahead/common/searchproject/') ->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 right wide'); $pri_map = ManiphestTaskPriority::getShortNameMap(); foreach ($pri_map as $pri => $label) { $cname[] = $label; $cclass[] = 'n'; } $cname[] = '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, $user); $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 AphrontPanelView(); $panel->setHeader($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((int)$constant); $open_status_list[] = json_encode((string)$constant); } $tasks = queryfx_all( $conn_r, 'SELECT t.* FROM %T t JOIN %T x ON x.objectPHID = t.phid WHERE t.status != 0 AND x.oldValue IN (null, %Ls) AND x.newValue NOT IN (%Ls) AND t.dateModified >= %d AND x.dateCreated >= %d', $table->getTableName(), $xtable->getTableName(), $open_status_list, $open_status_list, $window_epoch, $window_epoch); return id(new ManiphestTask())->loadAllFromArray($tasks); } /** * 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); } } diff --git a/src/applications/maniphest/editor/ManiphestTransactionEditor.php b/src/applications/maniphest/editor/ManiphestTransactionEditor.php index 4e8675c261..419c0db26e 100644 --- a/src/applications/maniphest/editor/ManiphestTransactionEditor.php +++ b/src/applications/maniphest/editor/ManiphestTransactionEditor.php @@ -1,466 +1,466 @@ getTransactionType()) { case ManiphestTransaction::TYPE_PRIORITY: if ($this->getIsNewObject()) { return null; } return (int)$object->getPriority(); case ManiphestTransaction::TYPE_STATUS: if ($this->getIsNewObject()) { return null; } - return (int)$object->getStatus(); + return $object->getStatus(); case ManiphestTransaction::TYPE_TITLE: if ($this->getIsNewObject()) { return null; } return $object->getTitle(); case ManiphestTransaction::TYPE_DESCRIPTION: if ($this->getIsNewObject()) { return null; } return $object->getDescription(); case ManiphestTransaction::TYPE_OWNER: return nonempty($object->getOwnerPHID(), null); case ManiphestTransaction::TYPE_CCS: return array_values(array_unique($object->getCCPHIDs())); case ManiphestTransaction::TYPE_PROJECTS: return array_values(array_unique($object->getProjectPHIDs())); case ManiphestTransaction::TYPE_ATTACH: return $object->getAttached(); case ManiphestTransaction::TYPE_EDGE: case ManiphestTransaction::TYPE_PROJECT_COLUMN: // These are pre-populated. return $xaction->getOldValue(); case ManiphestTransaction::TYPE_SUBPRIORITY: return $object->getSubpriority(); } } protected function getCustomTransactionNewValue( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case ManiphestTransaction::TYPE_PRIORITY: - case ManiphestTransaction::TYPE_STATUS: return (int)$xaction->getNewValue(); case ManiphestTransaction::TYPE_CCS: case ManiphestTransaction::TYPE_PROJECTS: return array_values(array_unique($xaction->getNewValue())); case ManiphestTransaction::TYPE_OWNER: return nonempty($xaction->getNewValue(), null); + case ManiphestTransaction::TYPE_STATUS: case ManiphestTransaction::TYPE_TITLE: case ManiphestTransaction::TYPE_DESCRIPTION: case ManiphestTransaction::TYPE_ATTACH: case ManiphestTransaction::TYPE_EDGE: case ManiphestTransaction::TYPE_SUBPRIORITY: case ManiphestTransaction::TYPE_PROJECT_COLUMN: return $xaction->getNewValue(); } } protected function transactionHasEffect( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { $old = $xaction->getOldValue(); $new = $xaction->getNewValue(); switch ($xaction->getTransactionType()) { case ManiphestTransaction::TYPE_PROJECTS: case ManiphestTransaction::TYPE_CCS: sort($old); sort($new); return ($old !== $new); case ManiphestTransaction::TYPE_PROJECT_COLUMN: $new_column_phids = $new['columnPHIDs']; $old_column_phids = $old['columnPHIDs']; sort($new_column_phids); sort($old_column_phids); return ($old !== $new); } return parent::transactionHasEffect($object, $xaction); } protected function applyCustomInternalTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case ManiphestTransaction::TYPE_PRIORITY: return $object->setPriority($xaction->getNewValue()); case ManiphestTransaction::TYPE_STATUS: return $object->setStatus($xaction->getNewValue()); case ManiphestTransaction::TYPE_TITLE: return $object->setTitle($xaction->getNewValue()); case ManiphestTransaction::TYPE_DESCRIPTION: return $object->setDescription($xaction->getNewValue()); case ManiphestTransaction::TYPE_OWNER: $phid = $xaction->getNewValue(); // Update the "ownerOrdering" column to contain the full name of the // owner, if the task is assigned. $handle = null; if ($phid) { $handle = id(new PhabricatorHandleQuery()) ->setViewer($this->getActor()) ->withPHIDs(array($phid)) ->executeOne(); } if ($handle) { $object->setOwnerOrdering($handle->getName()); } else { $object->setOwnerOrdering(null); } return $object->setOwnerPHID($phid); case ManiphestTransaction::TYPE_CCS: return $object->setCCPHIDs($xaction->getNewValue()); case ManiphestTransaction::TYPE_PROJECTS: return $object->setProjectPHIDs($xaction->getNewValue()); case ManiphestTransaction::TYPE_ATTACH: return $object->setAttached($xaction->getNewValue()); case ManiphestTransaction::TYPE_EDGE: // These are a weird, funky mess and are already being applied by the // time we reach this. return; case ManiphestTransaction::TYPE_SUBPRIORITY: $data = $xaction->getNewValue(); $new_sub = $this->getNextSubpriority( $data['newPriority'], $data['newSubpriorityBase']); $object->setSubpriority($new_sub); return; case ManiphestTransaction::TYPE_PROJECT_COLUMN: // these do external (edge) updates return; } } protected function expandTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { $xactions = parent::expandTransaction($object, $xaction); switch ($xaction->getTransactionType()) { case ManiphestTransaction::TYPE_SUBPRIORITY: $data = $xaction->getNewValue(); $new_pri = $data['newPriority']; if ($new_pri != $object->getPriority()) { $xactions[] = id(new ManiphestTransaction()) ->setTransactionType(ManiphestTransaction::TYPE_PRIORITY) ->setNewValue($new_pri); } break; default: break; } return $xactions; } protected function applyCustomExternalTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case ManiphestTransaction::TYPE_PROJECT_COLUMN: $new = $xaction->getNewValue(); $old = $xaction->getOldValue(); $src = $object->getPHID(); $dst = head($new['columnPHIDs']); $edges = $old['columnPHIDs']; $edge_type = PhabricatorEdgeConfig::TYPE_OBJECT_HAS_COLUMN; // NOTE: Normally, we expect only one edge to exist, but this works in // a general way so it will repair any stray edges. $remove = array(); $edge_missing = true; foreach ($edges as $phid) { if ($phid == $dst) { $edge_missing = false; } else { $remove[] = $phid; } } $add = array(); if ($edge_missing) { $add[] = $dst; } // This should never happen because of the code in // transactionHasEffect, but keep it for maximum conservativeness if (!$add && !$remove) { return; } $editor = id(new PhabricatorEdgeEditor()) ->setActor($this->getActor()) ->setSuppressEvents(true); foreach ($add as $phid) { $editor->addEdge($src, $edge_type, $phid); } foreach ($remove as $phid) { $editor->removeEdge($src, $edge_type, $phid); } $editor->save(); break; default: break; } } protected function shouldSendMail( PhabricatorLiskDAO $object, array $xactions) { $xactions = mfilter($xactions, 'shouldHide', true); return $xactions; } protected function getMailSubjectPrefix() { return PhabricatorEnv::getEnvConfig('metamta.maniphest.subject-prefix'); } protected function getMailThreadID(PhabricatorLiskDAO $object) { return 'maniphest-task-'.$object->getPHID(); } protected function getMailTo(PhabricatorLiskDAO $object) { return array( $object->getOwnerPHID(), $this->requireActor()->getPHID(), ); } protected function getMailCC(PhabricatorLiskDAO $object) { $phids = array(); foreach ($object->getCCPHIDs() as $phid) { $phids[] = $phid; } foreach ($this->heraldEmailPHIDs as $phid) { $phids[] = $phid; } return $phids; } protected function buildReplyHandler(PhabricatorLiskDAO $object) { return id(new ManiphestReplyHandler()) ->setMailReceiver($object); } protected function buildMailTemplate(PhabricatorLiskDAO $object) { $id = $object->getID(); $title = $object->getTitle(); return id(new PhabricatorMetaMTAMail()) ->setSubject("T{$id}: {$title}") ->addHeader('Thread-Topic', "T{$id}: ".$object->getOriginalTitle()); } protected function buildMailBody( PhabricatorLiskDAO $object, array $xactions) { $body = parent::buildMailBody($object, $xactions); if ($this->getIsNewObject()) { $body->addTextSection( pht('TASK DESCRIPTION'), $object->getDescription()); } $body->addTextSection( pht('TASK DETAIL'), PhabricatorEnv::getProductionURI('/T'.$object->getID())); return $body; } protected function shouldPublishFeedStory( PhabricatorLiskDAO $object, array $xactions) { return $this->shouldSendMail($object, $xactions); } protected function supportsSearch() { return true; } protected function shouldApplyHeraldRules( PhabricatorLiskDAO $object, array $xactions) { return true; } protected function buildHeraldAdapter( PhabricatorLiskDAO $object, array $xactions) { return id(new HeraldManiphestTaskAdapter()) ->setTask($object); } protected function didApplyHeraldRules( PhabricatorLiskDAO $object, HeraldAdapter $adapter, HeraldTranscript $transcript) { // TODO: Convert these to transactions. The way Maniphest deals with these // transactions is currently unconventional and messy. $save_again = false; $cc_phids = $adapter->getCcPHIDs(); if ($cc_phids) { $existing_cc = $object->getCCPHIDs(); $new_cc = array_unique(array_merge($cc_phids, $existing_cc)); $object->setCCPHIDs($new_cc); $save_again = true; } $project_phids = $adapter->getProjectPHIDs(); if ($project_phids) { $existing_projects = $object->getProjectPHIDs(); $new_projects = array_unique( array_merge($project_phids, $existing_projects)); $object->setProjectPHIDs($new_projects); $save_again = true; } if ($save_again) { $object->save(); } $this->heraldEmailPHIDs = $adapter->getEmailPHIDs(); $xactions = array(); $assign_phid = $adapter->getAssignPHID(); if ($assign_phid) { $xactions[] = id(new ManiphestTransaction()) ->setTransactionType(ManiphestTransaction::TYPE_OWNER) ->setNewValue($assign_phid); } return $xactions; } protected function requireCapabilities( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { parent::requireCapabilities($object, $xaction); $app_capability_map = array( ManiphestTransaction::TYPE_PRIORITY => ManiphestCapabilityEditPriority::CAPABILITY, ManiphestTransaction::TYPE_STATUS => ManiphestCapabilityEditStatus::CAPABILITY, ManiphestTransaction::TYPE_PROJECTS => ManiphestCapabilityEditProjects::CAPABILITY, ManiphestTransaction::TYPE_OWNER => ManiphestCapabilityEditAssign::CAPABILITY, PhabricatorTransactions::TYPE_EDIT_POLICY => ManiphestCapabilityEditPolicies::CAPABILITY, PhabricatorTransactions::TYPE_VIEW_POLICY => ManiphestCapabilityEditPolicies::CAPABILITY, ); $transaction_type = $xaction->getTransactionType(); $app_capability = idx($app_capability_map, $transaction_type); if ($app_capability) { $app = id(new PhabricatorApplicationQuery()) ->setViewer($this->getActor()) ->withClasses(array('PhabricatorApplicationManiphest')) ->executeOne(); PhabricatorPolicyFilter::requireCapability( $this->getActor(), $app, $app_capability); } } protected function adjustObjectForPolicyChecks( PhabricatorLiskDAO $object, array $xactions) { $copy = parent::adjustObjectForPolicyChecks($object, $xactions); foreach ($xactions as $xaction) { switch ($xaction->getTransactionType()) { case ManiphestTransaction::TYPE_OWNER: $copy->setOwnerPHID($xaction->getNewValue()); break; default: continue; } } return $copy; } private function getNextSubpriority($pri, $sub) { if ($sub === null) { $next = id(new ManiphestTask())->loadOneWhere( 'priority = %d ORDER BY subpriority ASC LIMIT 1', $pri); if ($next) { return $next->getSubpriority() - ((double)(2 << 16)); } } else { $next = id(new ManiphestTask())->loadOneWhere( 'priority = %d AND subpriority > %s ORDER BY subpriority ASC LIMIT 1', $pri, $sub); if ($next) { return ($sub + $next->getSubpriority()) / 2; } } return (double)(2 << 32); } } diff --git a/src/applications/maniphest/query/ManiphestTaskQuery.php b/src/applications/maniphest/query/ManiphestTaskQuery.php index 7f791b3661..f3cc7be1ab 100644 --- a/src/applications/maniphest/query/ManiphestTaskQuery.php +++ b/src/applications/maniphest/query/ManiphestTaskQuery.php @@ -1,927 +1,927 @@ authorPHIDs = $authors; return $this; } public function withIDs(array $ids) { $this->taskIDs = $ids; return $this; } public function withPHIDs(array $phids) { $this->taskPHIDs = $phids; return $this; } public function withOwners(array $owners) { $this->includeUnowned = false; foreach ($owners as $k => $phid) { if ($phid == ManiphestTaskOwner::OWNER_UP_FOR_GRABS || $phid === null) { $this->includeUnowned = true; unset($owners[$k]); break; } } $this->ownerPHIDs = $owners; return $this; } public function withAllProjects(array $projects) { $this->includeNoProject = false; foreach ($projects as $k => $phid) { if ($phid == ManiphestTaskOwner::PROJECT_NO_PROJECT) { $this->includeNoProject = true; unset($projects[$k]); } } $this->projectPHIDs = $projects; return $this; } public function withoutProjects(array $projects) { $this->xprojectPHIDs = $projects; return $this; } public function withStatus($status) { $this->status = $status; return $this; } public function withStatuses(array $statuses) { $this->statuses = $statuses; return $this; } public function withPriorities(array $priorities) { $this->priorities = $priorities; return $this; } public function withSubscribers(array $subscribers) { $this->subscriberPHIDs = $subscribers; return $this; } public function withFullTextSearch($fulltext_search) { $this->fullTextSearch = $fulltext_search; return $this; } public function setGroupBy($group) { $this->groupBy = $group; return $this; } public function setOrderBy($order) { $this->orderBy = $order; return $this; } public function withAnyProjects(array $projects) { $this->anyProjectPHIDs = $projects; return $this; } public function withAnyUserProjects(array $users) { $this->anyUserProjectPHIDs = $users; return $this; } public function withDateCreatedBefore($date_created_before) { $this->dateCreatedBefore = $date_created_before; return $this; } public function withDateCreatedAfter($date_created_after) { $this->dateCreatedAfter = $date_created_after; return $this; } public function withDateModifiedBefore($date_modified_before) { $this->dateModifiedBefore = $date_modified_before; return $this; } public function withDateModifiedAfter($date_modified_after) { $this->dateModifiedAfter = $date_modified_after; return $this; } public function loadPage() { // TODO: (T603) It is possible for a user to find the PHID of a project // they can't see, then query for tasks in that project and deduce the // identity of unknown/invisible projects. Before we allow the user to // execute a project-based PHID query, we should verify that they // can see the project. $task_dao = new ManiphestTask(); $conn = $task_dao->establishConnection('r'); $where = array(); $where[] = $this->buildTaskIDsWhereClause($conn); $where[] = $this->buildTaskPHIDsWhereClause($conn); $where[] = $this->buildStatusWhereClause($conn); $where[] = $this->buildStatusesWhereClause($conn); $where[] = $this->buildPrioritiesWhereClause($conn); $where[] = $this->buildAuthorWhereClause($conn); $where[] = $this->buildOwnerWhereClause($conn); $where[] = $this->buildSubscriberWhereClause($conn); $where[] = $this->buildProjectWhereClause($conn); $where[] = $this->buildAnyProjectWhereClause($conn); $where[] = $this->buildAnyUserProjectWhereClause($conn); $where[] = $this->buildXProjectWhereClause($conn); $where[] = $this->buildFullTextWhereClause($conn); if ($this->dateCreatedAfter) { $where[] = qsprintf( $conn, 'dateCreated >= %d', $this->dateCreatedAfter); } if ($this->dateCreatedBefore) { $where[] = qsprintf( $conn, 'dateCreated <= %d', $this->dateCreatedBefore); } if ($this->dateModifiedAfter) { $where[] = qsprintf( $conn, 'dateModified >= %d', $this->dateModifiedAfter); } if ($this->dateModifiedBefore) { $where[] = qsprintf( $conn, 'dateModified <= %d', $this->dateModifiedBefore); } $where[] = $this->buildPagingClause($conn); $where = $this->formatWhereClause($where); $having = ''; $count = ''; if (count($this->projectPHIDs) > 1) { // We want to treat the query as an intersection query, not a union // query. We sum the project count and require it be the same as the // number of projects we're searching for. $count = ', COUNT(project.projectPHID) projectCount'; $having = qsprintf( $conn, 'HAVING projectCount = %d', count($this->projectPHIDs)); } $order = $this->buildCustomOrderClause($conn); // TODO: Clean up this nonstandardness. if (!$this->getLimit()) { $this->setLimit(self::DEFAULT_PAGE_SIZE); } $group_column = ''; switch ($this->groupBy) { case self::GROUP_PROJECT: $group_column = qsprintf( $conn, ', projectGroupName.indexedObjectPHID projectGroupPHID'); break; } $rows = queryfx_all( $conn, 'SELECT task.* %Q %Q FROM %T task %Q %Q %Q %Q %Q %Q', $count, $group_column, $task_dao->getTableName(), $this->buildJoinsClause($conn), $where, $this->buildGroupClause($conn), $having, $order, $this->buildLimitClause($conn)); switch ($this->groupBy) { case self::GROUP_PROJECT: $data = ipull($rows, null, 'id'); break; default: $data = $rows; break; } $tasks = $task_dao->loadAllFromArray($data); switch ($this->groupBy) { case self::GROUP_PROJECT: $results = array(); foreach ($rows as $row) { $task = clone $tasks[$row['id']]; $task->attachGroupByProjectPHID($row['projectGroupPHID']); $results[] = $task; } $tasks = $results; break; } return $tasks; } protected function willFilterPage(array $tasks) { if ($this->groupBy == self::GROUP_PROJECT) { // We should only return project groups which the user can actually see. $project_phids = mpull($tasks, 'getGroupByProjectPHID'); $projects = id(new PhabricatorProjectQuery()) ->setViewer($this->getViewer()) ->withPHIDs($project_phids) ->execute(); $projects = mpull($projects, null, 'getPHID'); foreach ($tasks as $key => $task) { if (empty($projects[$task->getGroupByProjectPHID()])) { unset($tasks[$key]); } } } return $tasks; } private function buildTaskIDsWhereClause(AphrontDatabaseConnection $conn) { if (!$this->taskIDs) { return null; } return qsprintf( $conn, 'id in (%Ld)', $this->taskIDs); } private function buildTaskPHIDsWhereClause(AphrontDatabaseConnection $conn) { if (!$this->taskPHIDs) { return null; } return qsprintf( $conn, 'phid in (%Ls)', $this->taskPHIDs); } private function buildStatusWhereClause(AphrontDatabaseConnection $conn) { static $map = array( self::STATUS_RESOLVED => ManiphestTaskStatus::STATUS_CLOSED_RESOLVED, self::STATUS_WONTFIX => ManiphestTaskStatus::STATUS_CLOSED_WONTFIX, self::STATUS_INVALID => ManiphestTaskStatus::STATUS_CLOSED_INVALID, self::STATUS_SPITE => ManiphestTaskStatus::STATUS_CLOSED_SPITE, self::STATUS_DUPLICATE => ManiphestTaskStatus::STATUS_CLOSED_DUPLICATE, ); switch ($this->status) { case self::STATUS_ANY: return null; case self::STATUS_OPEN: return qsprintf( $conn, - 'status IN (%Ld)', + 'status IN (%Ls)', ManiphestTaskStatus::getOpenStatusConstants()); case self::STATUS_CLOSED: return qsprintf( $conn, - 'status IN (%Ld)', + 'status IN (%Ls)', ManiphestTaskStatus::getClosedStatusConstants()); default: $constant = idx($map, $this->status); if (!$constant) { throw new Exception("Unknown status query '{$this->status}'!"); } return qsprintf( $conn, - 'status = %d', + 'status = %s', $constant); } } private function buildStatusesWhereClause(AphrontDatabaseConnection $conn) { if ($this->statuses) { return qsprintf( $conn, - 'status IN (%Ld)', + 'status IN (%Ls)', $this->statuses); } return null; } private function buildPrioritiesWhereClause(AphrontDatabaseConnection $conn) { if ($this->priorities) { return qsprintf( $conn, 'priority IN (%Ld)', $this->priorities); } return null; } private function buildAuthorWhereClause(AphrontDatabaseConnection $conn) { if (!$this->authorPHIDs) { return null; } return qsprintf( $conn, 'authorPHID in (%Ls)', $this->authorPHIDs); } private function buildOwnerWhereClause(AphrontDatabaseConnection $conn) { if (!$this->ownerPHIDs) { if ($this->includeUnowned === null) { return null; } else if ($this->includeUnowned) { return qsprintf( $conn, 'ownerPHID IS NULL'); } else { return qsprintf( $conn, 'ownerPHID IS NOT NULL'); } } if ($this->includeUnowned) { return qsprintf( $conn, 'ownerPHID IN (%Ls) OR ownerPHID IS NULL', $this->ownerPHIDs); } else { return qsprintf( $conn, 'ownerPHID IN (%Ls)', $this->ownerPHIDs); } } private function buildFullTextWhereClause(AphrontDatabaseConnection $conn) { if (!strlen($this->fullTextSearch)) { return null; } // In doing a fulltext search, we first find all the PHIDs that match the // fulltext search, and then use that to limit the rest of the search $fulltext_query = id(new PhabricatorSavedQuery()) ->setEngineClassName('PhabricatorSearchApplicaionSearchEngine') ->setParameter('query', $this->fullTextSearch); // NOTE: Setting this to something larger than 2^53 will raise errors in // ElasticSearch, and billions of results won't fit in memory anyway. $fulltext_query->setParameter('limit', 100000); $fulltext_query->setParameter('type', ManiphestPHIDTypeTask::TYPECONST); $engine = PhabricatorSearchEngineSelector::newSelector()->newEngine(); $fulltext_results = $engine->executeSearch($fulltext_query); if (empty($fulltext_results)) { $fulltext_results = array(null); } return qsprintf( $conn, 'phid IN (%Ls)', $fulltext_results); } private function buildSubscriberWhereClause(AphrontDatabaseConnection $conn) { if (!$this->subscriberPHIDs) { return null; } return qsprintf( $conn, 'subscriber.subscriberPHID IN (%Ls)', $this->subscriberPHIDs); } private function buildProjectWhereClause(AphrontDatabaseConnection $conn) { if (!$this->projectPHIDs && !$this->includeNoProject) { return null; } $parts = array(); if ($this->projectPHIDs) { $parts[] = qsprintf( $conn, 'project.projectPHID in (%Ls)', $this->projectPHIDs); } if ($this->includeNoProject) { $parts[] = qsprintf( $conn, 'project.projectPHID IS NULL'); } return '('.implode(') OR (', $parts).')'; } private function buildAnyProjectWhereClause(AphrontDatabaseConnection $conn) { if (!$this->anyProjectPHIDs) { return null; } return qsprintf( $conn, 'anyproject.projectPHID IN (%Ls)', $this->anyProjectPHIDs); } private function buildAnyUserProjectWhereClause( AphrontDatabaseConnection $conn) { if (!$this->anyUserProjectPHIDs) { return null; } $projects = id(new PhabricatorProjectQuery()) ->setViewer($this->getViewer()) ->withMemberPHIDs($this->anyUserProjectPHIDs) ->execute(); $any_user_project_phids = mpull($projects, 'getPHID'); if (!$any_user_project_phids) { throw new PhabricatorEmptyQueryException(); } return qsprintf( $conn, 'anyproject.projectPHID IN (%Ls)', $any_user_project_phids); } private function buildXProjectWhereClause(AphrontDatabaseConnection $conn) { if (!$this->xprojectPHIDs) { return null; } return qsprintf( $conn, 'xproject.projectPHID IS NULL'); } private function buildCustomOrderClause(AphrontDatabaseConnection $conn) { $order = array(); switch ($this->groupBy) { case self::GROUP_NONE: break; case self::GROUP_PRIORITY: $order[] = 'priority'; break; case self::GROUP_OWNER: $order[] = 'ownerOrdering'; break; case self::GROUP_STATUS: $order[] = 'status'; break; case self::GROUP_PROJECT: $order[] = ''; break; default: throw new Exception("Unknown group query '{$this->groupBy}'!"); } switch ($this->orderBy) { case self::ORDER_PRIORITY: $order[] = 'priority'; $order[] = 'subpriority'; $order[] = 'dateModified'; break; case self::ORDER_CREATED: $order[] = 'id'; break; case self::ORDER_MODIFIED: $order[] = 'dateModified'; break; case self::ORDER_TITLE: $order[] = 'title'; break; default: throw new Exception("Unknown order query '{$this->orderBy}'!"); } $order = array_unique($order); if (empty($order)) { return null; } $reverse = ($this->getBeforeID() xor $this->getReversePaging()); foreach ($order as $k => $column) { switch ($column) { case 'subpriority': case 'ownerOrdering': case 'title': if ($reverse) { $order[$k] = "task.{$column} DESC"; } else { $order[$k] = "task.{$column} ASC"; } break; case '': // Put "No Project" at the end of the list. if ($reverse) { $order[$k] = 'projectGroupName.indexedObjectName IS NULL DESC, '. 'projectGroupName.indexedObjectName DESC'; } else { $order[$k] = 'projectGroupName.indexedObjectName IS NULL ASC, '. 'projectGroupName.indexedObjectName ASC'; } break; default: if ($reverse) { $order[$k] = "task.{$column} ASC"; } else { $order[$k] = "task.{$column} DESC"; } break; } } return 'ORDER BY '.implode(', ', $order); } private function buildJoinsClause(AphrontDatabaseConnection $conn_r) { $project_dao = new ManiphestTaskProject(); $joins = array(); if ($this->projectPHIDs || $this->includeNoProject) { $joins[] = qsprintf( $conn_r, '%Q JOIN %T project ON project.taskPHID = task.phid', ($this->includeNoProject ? 'LEFT' : ''), $project_dao->getTableName()); } if ($this->anyProjectPHIDs || $this->anyUserProjectPHIDs) { $joins[] = qsprintf( $conn_r, 'JOIN %T anyproject ON anyproject.taskPHID = task.phid', $project_dao->getTableName()); } if ($this->xprojectPHIDs) { $joins[] = qsprintf( $conn_r, 'LEFT JOIN %T xproject ON xproject.taskPHID = task.phid AND xproject.projectPHID IN (%Ls)', $project_dao->getTableName(), $this->xprojectPHIDs); } if ($this->subscriberPHIDs) { $subscriber_dao = new ManiphestTaskSubscriber(); $joins[] = qsprintf( $conn_r, 'JOIN %T subscriber ON subscriber.taskPHID = task.phid', $subscriber_dao->getTableName()); } switch ($this->groupBy) { case self::GROUP_PROJECT: $ignore_group_phids = $this->getIgnoreGroupedProjectPHIDs(); if ($ignore_group_phids) { $joins[] = qsprintf( $conn_r, 'LEFT JOIN %T projectGroup ON task.phid = projectGroup.taskPHID AND projectGroup.projectPHID NOT IN (%Ls)', $project_dao->getTableName(), $ignore_group_phids); } else { $joins[] = qsprintf( $conn_r, 'LEFT JOIN %T projectGroup ON task.phid = projectGroup.taskPHID', $project_dao->getTableName()); } $joins[] = qsprintf( $conn_r, 'LEFT JOIN %T projectGroupName ON projectGroup.projectPHID = projectGroupName.indexedObjectPHID', id(new ManiphestNameIndex())->getTableName()); break; } $joins[] = $this->buildApplicationSearchJoinClause($conn_r); return implode(' ', $joins); } private function buildGroupClause(AphrontDatabaseConnection $conn_r) { $joined_multiple_rows = (count($this->projectPHIDs) > 1) || (count($this->anyProjectPHIDs) > 1) || ($this->getApplicationSearchMayJoinMultipleRows()); $joined_project_name = ($this->groupBy == self::GROUP_PROJECT); // If we're joining multiple rows, we need to group the results by the // task IDs. if ($joined_multiple_rows) { if ($joined_project_name) { return 'GROUP BY task.phid, projectGroup.projectPHID'; } else { return 'GROUP BY task.phid'; } } else { return ''; } } /** * Return project PHIDs which we should ignore when grouping tasks by * project. For example, if a user issues a query like: * * Tasks in all projects: Frontend, Bugs * * ...then we don't show "Frontend" or "Bugs" groups in the result set, since * they're meaningless as all results are in both groups. * * Similarly, for queries like: * * Tasks in any projects: Public Relations * * ...we ignore the single project, as every result is in that project. (In * the case that there are several "any" projects, we do not ignore them.) * * @return list Project PHIDs which should be ignored in query * construction. */ private function getIgnoreGroupedProjectPHIDs() { $phids = array(); if ($this->projectPHIDs) { $phids[] = $this->projectPHIDs; } if (count($this->anyProjectPHIDs) == 1) { $phids[] = $this->anyProjectPHIDs; } // Maybe we should also exclude the "excludeProjectPHIDs"? It won't // impact the results, but we might end up with a better query plan. // Investigate this on real data? This is likely very rare. return array_mergev($phids); } private function loadCursorObject($id) { $results = id(new ManiphestTaskQuery()) ->setViewer($this->getPagingViewer()) ->withIDs(array((int)$id)) ->execute(); return head($results); } protected function getPagingValue($result) { $id = $result->getID(); switch ($this->groupBy) { case self::GROUP_NONE: return $id; case self::GROUP_PRIORITY: return $id.'.'.$result->getPriority(); case self::GROUP_OWNER: return rtrim($id.'.'.$result->getOwnerPHID(), '.'); case self::GROUP_STATUS: return $id.'.'.$result->getStatus(); case self::GROUP_PROJECT: return rtrim($id.'.'.$result->getGroupByProjectPHID(), '.'); default: throw new Exception("Unknown group query '{$this->groupBy}'!"); } } protected function buildPagingClause(AphrontDatabaseConnection $conn_r) { $default = parent::buildPagingClause($conn_r); $before_id = $this->getBeforeID(); $after_id = $this->getAfterID(); if (!$before_id && !$after_id) { return $default; } $cursor_id = nonempty($before_id, $after_id); $cursor_parts = explode('.', $cursor_id, 2); $task_id = $cursor_parts[0]; $group_id = idx($cursor_parts, 1); $cursor = $this->loadCursorObject($task_id); if (!$cursor) { return null; } $columns = array(); switch ($this->groupBy) { case self::GROUP_NONE: break; case self::GROUP_PRIORITY: $columns[] = array( 'name' => 'task.priority', 'value' => (int)$group_id, 'type' => 'int', ); break; case self::GROUP_OWNER: $columns[] = array( 'name' => '(task.ownerOrdering IS NULL)', 'value' => (int)(strlen($group_id) ? 0 : 1), 'type' => 'int', ); if ($group_id) { $paging_users = id(new PhabricatorPeopleQuery()) ->setViewer($this->getViewer()) ->withPHIDs(array($group_id)) ->execute(); if (!$paging_users) { return null; } $columns[] = array( 'name' => 'task.ownerOrdering', 'value' => head($paging_users)->getUsername(), 'type' => 'string', 'reverse' => true, ); } break; case self::GROUP_STATUS: $columns[] = array( 'name' => 'task.status', - 'value' => (int)$group_id, - 'type' => 'int', + 'value' => $group_id, + 'type' => 'string', ); break; case self::GROUP_PROJECT: $columns[] = array( 'name' => '(projectGroupName.indexedObjectName IS NULL)', 'value' => (int)(strlen($group_id) ? 0 : 1), 'type' => 'int', ); if ($group_id) { $paging_projects = id(new PhabricatorProjectQuery()) ->setViewer($this->getViewer()) ->withPHIDs(array($group_id)) ->execute(); if (!$paging_projects) { return null; } $columns[] = array( 'name' => 'projectGroupName.indexedObjectName', 'value' => head($paging_projects)->getName(), 'type' => 'string', 'reverse' => true, ); } break; default: throw new Exception("Unknown group query '{$this->groupBy}'!"); } switch ($this->orderBy) { case self::ORDER_PRIORITY: if ($this->groupBy != self::GROUP_PRIORITY) { $columns[] = array( 'name' => 'task.priority', 'value' => (int)$cursor->getPriority(), 'type' => 'int', ); } $columns[] = array( 'name' => 'task.subpriority', 'value' => (int)$cursor->getSubpriority(), 'type' => 'int', 'reverse' => true, ); $columns[] = array( 'name' => 'task.dateModified', 'value' => (int)$cursor->getDateModified(), 'type' => 'int', ); break; case self::ORDER_CREATED: $columns[] = array( 'name' => 'task.id', 'value' => (int)$cursor->getID(), 'type' => 'int', ); break; case self::ORDER_MODIFIED: $columns[] = array( 'name' => 'task.dateModified', 'value' => (int)$cursor->getDateModified(), 'type' => 'int', ); break; case self::ORDER_TITLE: $columns[] = array( 'name' => 'task.title', 'value' => $cursor->getTitle(), 'type' => 'string', ); $columns[] = array( 'name' => 'task.id', 'value' => $cursor->getID(), 'type' => 'int', ); break; default: throw new Exception("Unknown order query '{$this->orderBy}'!"); } return $this->buildPagingClauseFromMultipleColumns( $conn_r, $columns, array( 'reversed' => (bool)($before_id xor $this->getReversePaging()), )); } protected function getApplicationSearchObjectPHIDColumn() { return 'task.phid'; } public function getQueryApplicationClass() { return 'PhabricatorApplicationManiphest'; } }