diff --git a/src/applications/maniphest/editor/ManiphestTransactionEditor.php b/src/applications/maniphest/editor/ManiphestTransactionEditor.php index 2c8f7b8c05..f4069bf183 100644 --- a/src/applications/maniphest/editor/ManiphestTransactionEditor.php +++ b/src/applications/maniphest/editor/ManiphestTransactionEditor.php @@ -1,542 +1,538 @@ getTransactionType()) { case ManiphestTransaction::TYPE_PRIORITY: if ($this->getIsNewObject()) { return null; } return (int)$object->getPriority(); case ManiphestTransaction::TYPE_STATUS: if ($this->getIsNewObject()) { return null; } 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_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: return (int)$xaction->getNewValue(); case ManiphestTransaction::TYPE_CCS: 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_SUBPRIORITY: case ManiphestTransaction::TYPE_PROJECT_COLUMN: case ManiphestTransaction::TYPE_UNBLOCK: return $xaction->getNewValue(); } } protected function transactionHasEffect( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { $old = $xaction->getOldValue(); $new = $xaction->getNewValue(); switch ($xaction->getTransactionType()) { 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_SUBPRIORITY: $data = $xaction->getNewValue(); $new_sub = $this->getNextSubpriority( $data['newPriority'], $data['newSubpriorityBase'], $data['direction']); $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: $board_phid = idx($xaction->getNewValue(), 'projectPHID'); if (!$board_phid) { throw new Exception( pht("Expected 'projectPHID' in column transaction.")); } $new_phids = idx($xaction->getNewValue(), 'columnPHIDs', array()); if (count($new_phids) !== 1) { throw new Exception( pht("Expected exactly one 'columnPHIDs' in column transaction.")); } $positions = id(new PhabricatorProjectColumnPositionQuery()) ->setViewer($this->requireActor()) ->withObjectPHIDs(array($object->getPHID())) ->withBoardPHIDs(array($board_phid)) ->execute(); // Remove all existing column positions on the board. foreach ($positions as $position) { - if (!$position->getID()) { - // This is an ephemeral position, so don't try to destroy it. - continue; - } $position->delete(); } // Add the new column position. foreach ($new_phids as $phid) { id(new PhabricatorProjectColumnPosition()) ->setBoardPHID($board_phid) ->setColumnPHID($phid) ->setObjectPHID($object->getPHID()) // TODO: Do real sequence stuff. ->setSequence(0) ->save(); } break; default: break; } } protected function applyFinalEffects( PhabricatorLiskDAO $object, array $xactions) { // When we change the status of a task, update tasks this tasks blocks // with a message to the effect of "alincoln resolved blocking task Txxx." $unblock_xaction = null; foreach ($xactions as $xaction) { switch ($xaction->getTransactionType()) { case ManiphestTransaction::TYPE_STATUS: $unblock_xaction = $xaction; break; } } if ($unblock_xaction !== null) { $blocked_phids = PhabricatorEdgeQuery::loadDestinationPHIDs( $object->getPHID(), PhabricatorEdgeConfig::TYPE_TASK_DEPENDED_ON_BY_TASK); if ($blocked_phids) { // In theory we could apply these through policies, but that seems a // little bit surprising. For now, use the actor's vision. $blocked_tasks = id(new ManiphestTaskQuery()) ->setViewer($this->getActor()) ->withPHIDs($blocked_phids) ->execute(); $old = $unblock_xaction->getOldValue(); $new = $unblock_xaction->getNewValue(); foreach ($blocked_tasks as $blocked_task) { $unblock_xactions = array(); $unblock_xactions[] = id(new ManiphestTransaction()) ->setTransactionType(ManiphestTransaction::TYPE_UNBLOCK) ->setOldValue(array($object->getPHID() => $old)) ->setNewValue(array($object->getPHID() => $new)); // TODO: We should avoid notifiying users about these indirect // changes if they are getting a notification about the current // change, so you don't get a pile of extra notifications if you are // subscribed to this task. id(new ManiphestTransactionEditor()) ->setActor($this->getActor()) ->setContentSource($this->getContentSource()) ->setContinueOnNoEffect(true) ->setContinueOnMissingFields(true) ->applyTransactions($blocked_task, $unblock_xactions); } } } return $xactions; } 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 (parent::getMailCC($object) 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); $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); } $project_phids = $adapter->getProjectPHIDs(); if ($project_phids) { $project_type = PhabricatorProjectObjectHasProjectEdgeType::EDGECONST; $xactions[] = id(new ManiphestTransaction()) ->setTransactionType(PhabricatorTransactions::TYPE_EDGE) ->setMetadataValue('edge:type', $project_type) ->setNewValue( array( '+' => array_fuse($project_phids), )); } return $xactions; } protected function requireCapabilities( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { parent::requireCapabilities($object, $xaction); $app_capability_map = array( ManiphestTransaction::TYPE_PRIORITY => ManiphestEditPriorityCapability::CAPABILITY, ManiphestTransaction::TYPE_STATUS => ManiphestEditStatusCapability::CAPABILITY, ManiphestTransaction::TYPE_OWNER => ManiphestEditAssignCapability::CAPABILITY, PhabricatorTransactions::TYPE_EDIT_POLICY => ManiphestEditPoliciesCapability::CAPABILITY, PhabricatorTransactions::TYPE_VIEW_POLICY => ManiphestEditPoliciesCapability::CAPABILITY, ); $transaction_type = $xaction->getTransactionType(); $app_capability = null; if ($transaction_type == PhabricatorTransactions::TYPE_EDGE) { switch ($xaction->getMetadataValue('edge:type')) { case PhabricatorProjectObjectHasProjectEdgeType::EDGECONST: $app_capability = ManiphestEditProjectsCapability::CAPABILITY; break; } } else { $app_capability = idx($app_capability_map, $transaction_type); } if ($app_capability) { $app = id(new PhabricatorApplicationQuery()) ->setViewer($this->getActor()) ->withClasses(array('PhabricatorManiphestApplication')) ->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, $dir = '>') { switch ($dir) { case '>': $order = 'ASC'; break; case '<': $order = 'DESC'; break; default: throw new Exception('$dir must be ">" or "<".'); break; } if ($sub === null) { $base = 0; } else { $base = $sub; } if ($sub === null) { $next = id(new ManiphestTask())->loadOneWhere( 'priority = %d ORDER BY subpriority %Q LIMIT 1', $pri, $order); if ($next) { if ($dir == '>') { return $next->getSubpriority() - ((double)(2 << 16)); } else { return $next->getSubpriority() + ((double)(2 << 16)); } } } else { $next = id(new ManiphestTask())->loadOneWhere( 'priority = %d AND subpriority %Q %f ORDER BY subpriority %Q LIMIT 1', $pri, $dir, $sub, $order); if ($next) { return ($sub + $next->getSubpriority()) / 2; } } if ($dir == '>') { return $base + (double)(2 << 32); } else { return $base - (double)(2 << 32); } } } diff --git a/src/applications/project/query/PhabricatorProjectColumnPositionQuery.php b/src/applications/project/query/PhabricatorProjectColumnPositionQuery.php index 27786737e0..60e9b2de58 100644 --- a/src/applications/project/query/PhabricatorProjectColumnPositionQuery.php +++ b/src/applications/project/query/PhabricatorProjectColumnPositionQuery.php @@ -1,258 +1,290 @@ ids = $ids; return $this; } public function withBoardPHIDs(array $board_phids) { $this->boardPHIDs = $board_phids; return $this; } public function withObjectPHIDs(array $object_phids) { $this->objectPHIDs = $object_phids; return $this; } /** * Find objects in specific columns. * * NOTE: Using this method activates logic which constructs virtual * column positions for objects not in any column, if you pass a default * column. Normally these results are not returned. * * @param list Columns to look for objects in. * @return this */ public function withColumns(array $columns) { assert_instances_of($columns, 'PhabricatorProjectColumn'); $this->columns = $columns; return $this; } public function needColumns($need_columns) { $this->needColumns = true; return $this; } // NOTE: For now, boards are always attached to projects. However, they might // not be in the future. This generalization just anticipates a future where // we let other types of objects (like users) have boards, or let boards // contain other types of objects. private function newPositionObject() { return new PhabricatorProjectColumnPosition(); } private function newColumnQuery() { return new PhabricatorProjectColumnQuery(); } private function getBoardMembershipEdgeTypes() { return array( PhabricatorProjectProjectHasObjectEdgeType::EDGECONST, ); } private function getBoardMembershipPHIDTypes() { return array( ManiphestTaskPHIDType::TYPECONST, ); } protected function loadPage() { $table = $this->newPositionObject(); $conn_r = $table->establishConnection('r'); // We're going to find results by combining two queries: one query finds // objects on a board column, while the other query finds objects not on // any board column and virtually puts them on the default column. $unions = array(); // First, find all the stuff that's actually on a column. $unions[] = qsprintf( $conn_r, 'SELECT * FROM %T %Q', $table->getTableName(), $this->buildWhereClause($conn_r)); // If we have a default column, find all the stuff that's not in any // column and put it in the default column. $must_type_filter = false; if ($this->columns) { $default_map = array(); foreach ($this->columns as $column) { if ($column->isDefaultColumn()) { $default_map[$column->getProjectPHID()] = $column->getPHID(); } } if ($default_map) { $where = array(); // Find the edges attached to the boards we have default columns for. $where[] = qsprintf( $conn_r, 'e.src IN (%Ls)', array_keys($default_map)); // Find only edges which describe a board relationship. $where[] = qsprintf( $conn_r, 'e.type IN (%Ld)', $this->getBoardMembershipEdgeTypes()); if ($this->boardPHIDs !== null) { // This should normally be redundant, but construct it anyway if // the caller has told us to. $where[] = qsprintf( $conn_r, 'e.src IN (%Ls)', $this->boardPHIDs); } if ($this->objectPHIDs !== null) { $where[] = qsprintf( $conn_r, 'e.dst IN (%Ls)', $this->objectPHIDs); } $where[] = qsprintf( $conn_r, 'p.id IS NULL'); $where = $this->formatWhereClause($where); $unions[] = qsprintf( $conn_r, 'SELECT NULL id, e.src boardPHID, NULL columnPHID, e.dst objectPHID, 0 sequence FROM %T e LEFT JOIN %T p ON e.src = p.boardPHID AND e.dst = p.objectPHID %Q', PhabricatorEdgeConfig::TABLE_NAME_EDGE, $table->getTableName(), $where); $must_type_filter = true; } } $data = queryfx_all( $conn_r, '%Q %Q %Q', implode(' UNION ALL ', $unions), $this->buildOrderClause($conn_r), $this->buildLimitClause($conn_r)); // If we've picked up objects not in any column, we need to filter out any // matched objects which have the wrong edge type. if ($must_type_filter) { $allowed_types = array_fuse($this->getBoardMembershipPHIDTypes()); foreach ($data as $id => $row) { if ($row['columnPHID'] === null) { $object_phid = $row['objectPHID']; if (empty($allowed_types[phid_get_type($object_phid)])) { unset($data[$id]); } } } } $positions = $table->loadAllFromArray($data); + // Find the implied positions which don't exist yet. If there are any, + // we're going to create them. + $create = array(); foreach ($positions as $position) { if ($position->getColumnPHID() === null) { - $position->makeEphemeral(); $column_phid = idx($default_map, $position->getBoardPHID()); $position->setColumnPHID($column_phid); + + $create[] = $position; } } + if ($create) { + // If we're adding several objects to a column, insert the column + // position objects in object ID order. This means that newly added + // objects float to the top, and when a group of newly added objects + // float up at the same time, the most recently created ones end up + // highest in the list. + + $objects = id(new PhabricatorObjectQuery()) + ->setViewer(PhabricatorUser::getOmnipotentUser()) + ->withPHIDs(mpull($create, 'getObjectPHID')) + ->execute(); + $objects = mpull($objects, null, 'getPHID'); + $objects = msort($objects, 'getID'); + + $create = mgroup($create, 'getObjectPHID'); + $create = array_select_keys($create, array_keys($objects)) + $create; + + $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); + + foreach ($create as $object_phid => $create_positions) { + foreach ($create_positions as $create_position) { + $create_position->save(); + } + } + + unset($unguarded); + } + return $positions; } protected function willFilterPage(array $page) { if ($this->needColumns) { $column_phids = mpull($page, 'getColumnPHID'); $columns = $this->newColumnQuery() ->setParentQuery($this) ->setViewer($this->getViewer()) ->withPHIDs($column_phids) ->execute(); $columns = mpull($columns, null, 'getPHID'); foreach ($page as $key => $position) { $column = idx($columns, $position->getColumnPHID()); if (!$column) { unset($page[$key]); continue; } $position->attachColumn($column); } } return $page; } private function buildWhereClause($conn_r) { $where = array(); if ($this->ids !== null) { $where[] = qsprintf( $conn_r, 'id IN (%Ld)', $this->ids); } if ($this->boardPHIDs !== null) { $where[] = qsprintf( $conn_r, 'boardPHID IN (%Ls)', $this->boardPHIDs); } if ($this->objectPHIDs !== null) { $where[] = qsprintf( $conn_r, 'objectPHID IN (%Ls)', $this->objectPHIDs); } if ($this->columns !== null) { $where[] = qsprintf( $conn_r, 'columnPHID IN (%Ls)', mpull($this->columns, 'getPHID')); } // NOTE: Explicitly not building the paging clause here, since it won't // work with the UNION. return $this->formatWhereClause($where); } public function getQueryApplicationClass() { return 'PhabricatorProjectApplication'; } }