diff --git a/src/applications/maniphest/config/PhabricatorManiphestConfigOptions.php b/src/applications/maniphest/config/PhabricatorManiphestConfigOptions.php index cb011984bf..724309175d 100644 --- a/src/applications/maniphest/config/PhabricatorManiphestConfigOptions.php +++ b/src/applications/maniphest/config/PhabricatorManiphestConfigOptions.php @@ -1,383 +1,386 @@ array( 'name' => pht('Unbreak Now!'), 'short' => pht('Unbreak!'), 'color' => 'pink', 'keywords' => array('unbreak'), ), 90 => array( 'name' => pht('Needs Triage'), 'short' => pht('Triage'), 'color' => 'violet', 'keywords' => array('triage'), ), 80 => array( 'name' => pht('High'), 'short' => pht('High'), 'color' => 'red', 'keywords' => array('high'), ), 50 => array( 'name' => pht('Normal'), 'short' => pht('Normal'), 'color' => 'orange', 'keywords' => array('normal'), ), 25 => array( 'name' => pht('Low'), 'short' => pht('Low'), 'color' => 'yellow', 'keywords' => array('low'), ), 0 => array( 'name' => pht('Wishlist'), 'short' => pht('Wish'), 'color' => 'sky', 'keywords' => array('wish', 'wishlist'), ), ); $status_type = 'custom:ManiphestStatusConfigOptionType'; $status_defaults = array( 'open' => array( 'name' => pht('Open'), 'special' => ManiphestTaskStatus::SPECIAL_DEFAULT, 'prefixes' => array( 'open', 'opens', 'reopen', 'reopens', ), ), 'resolved' => array( 'name' => pht('Resolved'), 'name.full' => pht('Closed, Resolved'), 'closed' => true, 'special' => ManiphestTaskStatus::SPECIAL_CLOSED, 'prefixes' => array( 'closed', 'closes', 'close', 'fix', 'fixes', 'fixed', 'resolve', 'resolves', 'resolved', ), 'suffixes' => array( 'as resolved', 'as fixed', ), 'keywords' => array('closed', 'fixed', 'resolved'), ), 'wontfix' => array( 'name' => pht('Wontfix'), 'name.full' => pht('Closed, Wontfix'), 'closed' => true, 'prefixes' => array( 'wontfix', 'wontfixes', 'wontfixed', ), 'suffixes' => array( 'as wontfix', ), ), 'invalid' => array( 'name' => pht('Invalid'), 'name.full' => pht('Closed, Invalid'), 'closed' => true, + 'claim' => false, 'prefixes' => array( 'invalidate', 'invalidates', 'invalidated', ), 'suffixes' => array( 'as invalid', ), ), 'duplicate' => array( 'name' => pht('Duplicate'), 'name.full' => pht('Closed, Duplicate'), 'transaction.icon' => 'fa-files-o', 'special' => ManiphestTaskStatus::SPECIAL_DUPLICATE, 'closed' => true, + 'claim' => false, ), 'spite' => array( 'name' => pht('Spite'), 'name.full' => pht('Closed, Spite'), 'name.action' => pht('Spited'), 'transaction.icon' => 'fa-thumbs-o-down', 'silly' => true, 'closed' => true, 'prefixes' => array( 'spite', 'spites', 'spited', ), 'suffixes' => array( 'out of spite', 'as spite', ), ), ); $status_description = $this->deformat(pht(<<.// Allows you to specify a list of text prefixes which will trigger a task transition into this status when mentioned in a commit message. For example, providing "closes" here will allow users to move tasks to this status by writing `Closes T123` in commit messages. - `suffixes` //Optional list.// Allows you to specify a list of text suffixes which will trigger a task transition into this status when mentioned in a commit message, after a valid prefix. For example, providing "as invalid" here will allow users to move tasks to this status by writing `Closes T123 as invalid`, even if another status is selected by the "Closes" prefix. - `keywords` //Optional list.// Allows you to specify a list of keywords which can be used with `!status` commands in email to select this status. - `disabled` //Optional bool.// Marks this status as no longer in use so tasks can not be created or edited to have this status. Existing tasks with this status will not be affected, but you can batch edit them or let them die out on their own. + - `claim` //Optional bool.// By default, closing an unassigned task claims + it. You can set this to `false` to disable this behavior for a particular + status. Statuses will appear in the UI in the order specified. Note the status marked `special` as `duplicate` is not settable directly and will not appear in UI elements, and that any status marked `silly` does not appear if Phabricator is configured with `phabricator.serious-business` set to true. Examining the default configuration and examples below will probably be helpful in understanding these options. EOTEXT )); $status_example = array( 'open' => array( 'name' => pht('Open'), 'special' => 'default', ), 'closed' => array( 'name' => pht('Closed'), 'special' => 'closed', 'closed' => true, ), 'duplicate' => array( 'name' => pht('Duplicate'), 'special' => 'duplicate', 'closed' => true, ), ); $json = new PhutilJSON(); $status_example = $json->encodeFormatted($status_example); // This is intentionally blank for now, until we can move more Maniphest // logic to custom fields. $default_fields = array(); foreach ($default_fields as $key => $enabled) { $default_fields[$key] = array( 'disabled' => !$enabled, ); } $custom_field_type = 'custom:PhabricatorCustomFieldConfigOptionType'; $fields_example = array( 'mycompany.estimated-hours' => array( 'name' => pht('Estimated Hours'), 'type' => 'int', 'caption' => pht('Estimated number of hours this will take.'), ), ); $fields_json = id(new PhutilJSON())->encodeFormatted($fields_example); $points_type = 'custom:ManiphestPointsConfigOptionType'; $points_example_1 = array( 'enabled' => true, 'label' => pht('Story Points'), 'action' => pht('Change Story Points'), ); $points_json_1 = id(new PhutilJSON())->encodeFormatted($points_example_1); $points_example_2 = array( 'enabled' => true, 'label' => pht('Estimated Hours'), 'action' => pht('Change Estimate'), ); $points_json_2 = id(new PhutilJSON())->encodeFormatted($points_example_2); $points_description = $this->deformat(pht(<<newOption('maniphest.custom-field-definitions', 'wild', array()) ->setSummary(pht('Custom Maniphest fields.')) ->setDescription( pht( 'Array of custom fields for Maniphest tasks. For details on '. 'adding custom fields to Maniphest, see "Configuring Custom '. 'Fields" in the documentation.')) ->addExample($fields_json, pht('Valid setting')), $this->newOption('maniphest.fields', $custom_field_type, $default_fields) ->setCustomData(id(new ManiphestTask())->getCustomFieldBaseClass()) ->setDescription(pht('Select and reorder task fields.')), $this->newOption( 'maniphest.priorities', $priority_type, $priority_defaults) ->setSummary(pht('Configure Maniphest priority names.')) ->setDescription( pht( 'Allows you to edit or override the default priorities available '. 'in Maniphest, like "High", "Normal" and "Low". The configuration '. 'should contain a map of priority constants to priority '. 'specifications (see defaults below for examples).'. "\n\n". 'The keys you can define for a priority are:'. "\n\n". ' - `name` Name of the priority.'."\n". ' - `short` Alternate shorter name, used in UIs where there is '. ' not much space available.'."\n". ' - `color` A color for this priority, like "red" or "blue".'. ' - `keywords` An optional list of keywords which can '. ' be used to select this priority when using `!priority` '. ' commands in email.'."\n". ' - `disabled` Optional boolean to prevent users from choosing '. ' this priority when creating or editing tasks. Existing '. ' tasks will be unaffected, and can be batch edited to a '. ' different priority or left to eventually die out.'. "\n\n". 'You can choose which priority is the default for newly created '. 'tasks with `%s`.', 'maniphest.default-priority')), $this->newOption('maniphest.statuses', $status_type, $status_defaults) ->setSummary(pht('Configure Maniphest task statuses.')) ->setDescription($status_description) ->addExample($status_example, pht('Minimal Valid Config')), $this->newOption('maniphest.default-priority', 'int', 90) ->setSummary(pht('Default task priority for create flows.')) ->setDescription( pht( 'Choose a default priority for newly created tasks. You can '. 'review and adjust available priorities by using the '. '%s configuration option. The default value (`90`) '. 'corresponds to the default "Needs Triage" priority.', 'maniphest.priorities')), $this->newOption( 'metamta.maniphest.subject-prefix', 'string', '[Maniphest]') ->setDescription(pht('Subject prefix for Maniphest mail.')), $this->newOption( 'maniphest.priorities.unbreak-now', 'int', 100) ->setSummary(pht('Priority used to populate "Unbreak Now" on home.')) ->setDescription( pht( 'Temporary setting. If set, this priority is used to populate the '. '"Unbreak Now" panel on the home page. You should adjust this if '. 'you adjust priorities using `%s`.', 'maniphest.priorities')), $this->newOption( 'maniphest.priorities.needs-triage', 'int', 90) ->setSummary(pht('Priority used to populate "Needs Triage" on home.')) ->setDescription( pht( 'Temporary setting. If set, this priority is used to populate the '. '"Needs Triage" panel on the home page. You should adjust this if '. 'you adjust priorities using `%s`.', 'maniphest.priorities')), $this->newOption('maniphest.points', $points_type, array()) ->setSummary(pht('Configure point values for tasks.')) ->setDescription($points_description) ->addExample($points_json_1, pht('Points Config')) ->addExample($points_json_2, pht('Hours Config')), ); } } diff --git a/src/applications/maniphest/constants/ManiphestTaskStatus.php b/src/applications/maniphest/constants/ManiphestTaskStatus.php index ab99f212e5..3a839d8fac 100644 --- a/src/applications/maniphest/constants/ManiphestTaskStatus.php +++ b/src/applications/maniphest/constants/ManiphestTaskStatus.php @@ -1,360 +1,365 @@ $status) { if ($is_serious && !empty($status['silly'])) { unset($spec[$const]); continue; } } return $spec; } public static function getTaskStatusMap() { return ipull(self::getEnabledStatusMap(), 'name'); } /** * Get the statuses and their command keywords. * * @return map Statuses to lists of command keywords. */ public static function getTaskStatusKeywordsMap() { $map = self::getEnabledStatusMap(); foreach ($map as $key => $spec) { $words = idx($spec, 'keywords', array()); if (!is_array($words)) { $words = array($words); } // For statuses, we include the status name because it's usually // at least somewhat meaningful. $words[] = $key; foreach ($words as $word_key => $word) { $words[$word_key] = phutil_utf8_strtolower($word); } $words = array_unique($words); $map[$key] = $words; } return $map; } 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_color = 'bluegrey'; } else { $color = 'status-dark'; $icon_color = ''; } $icon = self::getStatusIcon($status); $img = id(new PHUIIconView()) ->setIcon($icon.' '.$icon_color); $tag = phutil_tag( 'span', array( 'class' => 'phui-header-status phui-header-'.$color, ), array( $img, self::getTaskStatusFullName($status), )); return $tag; } private static function getSpecialStatus($special) { foreach (self::getStatusConfig() 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 isClaimStatus($status) { + return self::getStatusAttribute($status, 'claim', true); + } + 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 isDisabledStatus($status) { return self::getStatusAttribute($status, 'disabled'); } public static function getStatusIcon($status) { $icon = self::getStatusAttribute($status, 'transaction.icon'); if ($icon) { return $icon; } if (self::isOpenStatus($status)) { return 'fa-exclamation-circle'; } else { return 'fa-check-square-o'; } } 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; } /* -( Configuration Validation )------------------------------------------- */ /** * @task validate */ public static function isValidStatusConstant($constant) { if (strlen($constant) > 12) { return false; } if (!preg_match('/^[a-z0-9]+\z/', $constant)) { return false; } return true; } /** * @task validate */ public static function validateConfiguration(array $config) { foreach ($config as $key => $value) { if (!self::isValidStatusConstant($key)) { throw new Exception( pht( 'Key "%s" is not a valid status constant. Status constants must '. 'be 1-12 characters long and contain only lowercase letters (a-z) '. 'and digits (0-9). For example, "%s" or "%s" are reasonable '. 'choices.', $key, 'open', 'closed')); } if (!is_array($value)) { throw new Exception( pht( 'Value for key "%s" should be a dictionary.', $key)); } PhutilTypeSpec::checkMap( $value, array( 'name' => 'string', 'name.full' => 'optional string', 'name.action' => 'optional string', 'closed' => 'optional bool', 'special' => 'optional string', 'transaction.icon' => 'optional string', 'transaction.color' => 'optional string', 'silly' => 'optional bool', 'prefixes' => 'optional list', 'suffixes' => 'optional list', 'keywords' => 'optional list', 'disabled' => 'optional bool', + 'claim' => 'optional bool', )); } $special_map = array(); foreach ($config as $key => $value) { $special = idx($value, 'special'); if (!$special) { continue; } if (isset($special_map[$special])) { throw new Exception( pht( 'Configuration has two statuses both marked with the special '. 'attribute "%s" ("%s" and "%s"). There should be only one.', $special, $special_map[$special], $key)); } switch ($special) { case self::SPECIAL_DEFAULT: if (!empty($value['closed'])) { throw new Exception( pht( 'Status "%s" is marked as default, but it is a closed '. 'status. The default status should be an open status.', $key)); } break; case self::SPECIAL_CLOSED: if (empty($value['closed'])) { throw new Exception( pht( 'Status "%s" is marked as the default status for closing '. 'tasks, but is not a closed status. It should be a closed '. 'status.', $key)); } break; case self::SPECIAL_DUPLICATE: if (empty($value['closed'])) { throw new Exception( pht( 'Status "%s" is marked as the status for closing tasks as '. 'duplicates, but it is not a closed status. It should '. 'be a closed status.', $key)); } break; } $special_map[$special] = $key; } // NOTE: We're not explicitly validating that we have at least one open // and one closed status, because the DEFAULT and CLOSED specials imply // that to be true. If those change in the future, that might become a // reasonable thing to validate. $required = array( self::SPECIAL_DEFAULT, self::SPECIAL_CLOSED, self::SPECIAL_DUPLICATE, ); foreach ($required as $required_special) { if (!isset($special_map[$required_special])) { throw new Exception( pht( 'Configuration defines no task status with special attribute '. '"%s", but you must specify a status which fills this special '. 'role.', $required_special)); } } } } diff --git a/src/applications/maniphest/editor/ManiphestTransactionEditor.php b/src/applications/maniphest/editor/ManiphestTransactionEditor.php index 922317b206..08cfc66632 100644 --- a/src/applications/maniphest/editor/ManiphestTransactionEditor.php +++ b/src/applications/maniphest/editor/ManiphestTransactionEditor.php @@ -1,1067 +1,1070 @@ 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_PROJECT_COLUMN: // These are pre-populated. return $xaction->getOldValue(); case ManiphestTransaction::TYPE_SUBPRIORITY: return $object->getSubpriority(); case ManiphestTransaction::TYPE_COVER_IMAGE: return $object->getCoverImageFilePHID(); case ManiphestTransaction::TYPE_POINTS: $points = $object->getPoints(); if ($points !== null) { $points = (double)$points; } return $points; case ManiphestTransaction::TYPE_MERGED_INTO: case ManiphestTransaction::TYPE_MERGED_FROM: return null; case ManiphestTransaction::TYPE_PARENT: case ManiphestTransaction::TYPE_COLUMN: return null; } } protected function getCustomTransactionNewValue( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case ManiphestTransaction::TYPE_PRIORITY: return (int)$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_MERGED_INTO: case ManiphestTransaction::TYPE_MERGED_FROM: case ManiphestTransaction::TYPE_UNBLOCK: case ManiphestTransaction::TYPE_COVER_IMAGE: return $xaction->getNewValue(); case ManiphestTransaction::TYPE_PARENT: case ManiphestTransaction::TYPE_COLUMN: return $xaction->getNewValue(); case ManiphestTransaction::TYPE_POINTS: $value = $xaction->getNewValue(); if (!strlen($value)) { $value = null; } if ($value !== null) { $value = (double)$value; } return $value; } } protected function transactionHasEffect( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { $old = $xaction->getOldValue(); $new = $xaction->getNewValue(); switch ($xaction->getTransactionType()) { 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_SUBPRIORITY: $object->setSubpriority($xaction->getNewValue()); return; case ManiphestTransaction::TYPE_PROJECT_COLUMN: // these do external (edge) updates return; case ManiphestTransaction::TYPE_MERGED_INTO: $object->setStatus(ManiphestTaskStatus::getDuplicateStatus()); return; case ManiphestTransaction::TYPE_COVER_IMAGE: $file_phid = $xaction->getNewValue(); if ($file_phid) { $file = id(new PhabricatorFileQuery()) ->setViewer($this->getActor()) ->withPHIDs(array($file_phid)) ->executeOne(); } else { $file = null; } if (!$file || !$file->isTransformableImage()) { $object->setProperty('cover.filePHID', null); $object->setProperty('cover.thumbnailPHID', null); return; } $xform_key = PhabricatorFileThumbnailTransform::TRANSFORM_WORKCARD; $xform = PhabricatorFileTransform::getTransformByKey($xform_key) ->executeTransform($file); $object->setProperty('cover.filePHID', $file->getPHID()); $object->setProperty('cover.thumbnailPHID', $xform->getPHID()); return; case ManiphestTransaction::TYPE_POINTS: $object->setPoints($xaction->getNewValue()); return; case ManiphestTransaction::TYPE_MERGED_FROM: case ManiphestTransaction::TYPE_PARENT: case ManiphestTransaction::TYPE_COLUMN: return; } } protected function applyCustomExternalTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case ManiphestTransaction::TYPE_PARENT: $parent_phid = $xaction->getNewValue(); $parent_type = ManiphestTaskDependsOnTaskEdgeType::EDGECONST; $task_phid = $object->getPHID(); id(new PhabricatorEdgeEditor()) ->addEdge($parent_phid, $parent_type, $task_phid) ->save(); break; case ManiphestTransaction::TYPE_PROJECT_COLUMN: $board_phid = idx($xaction->getNewValue(), 'projectPHID'); if (!$board_phid) { throw new Exception( pht( "Expected '%s' in column transaction.", 'projectPHID')); } $old_phids = idx($xaction->getOldValue(), 'columnPHIDs', array()); $new_phids = idx($xaction->getNewValue(), 'columnPHIDs', array()); if (count($new_phids) !== 1) { throw new Exception( pht( "Expected exactly one '%s' in column transaction.", 'columnPHIDs')); } $before_phid = idx($xaction->getNewValue(), 'beforePHID'); $after_phid = idx($xaction->getNewValue(), 'afterPHID'); if (!$before_phid && !$after_phid && ($old_phids == $new_phids)) { // If we are not moving the object between columns and also not // reordering the position, this is a move on some other order // (like priority). We can leave the positions untouched and just // bail, there's no work to be done. return; } // Otherwise, we're either moving between columns or adjusting the // object's position in the "natural" ordering, so we do need to update // some rows. $object_phid = $object->getPHID(); // We're doing layout with the ominpotent viewer to make sure we don't // remove positions in columns that exist, but which the actual actor // can't see. $omnipotent_viewer = PhabricatorUser::getOmnipotentUser(); $select_phids = array($board_phid); $descendants = id(new PhabricatorProjectQuery()) ->setViewer($omnipotent_viewer) ->withAncestorProjectPHIDs($select_phids) ->execute(); foreach ($descendants as $descendant) { $select_phids[] = $descendant->getPHID(); } $board_tasks = id(new ManiphestTaskQuery()) ->setViewer($omnipotent_viewer) ->withEdgeLogicPHIDs( PhabricatorProjectObjectHasProjectEdgeType::EDGECONST, PhabricatorQueryConstraint::OPERATOR_ANCESTOR, array($select_phids)) ->execute(); $object_phids = mpull($board_tasks, 'getPHID'); $object_phids[] = $object_phid; $engine = id(new PhabricatorBoardLayoutEngine()) ->setViewer($omnipotent_viewer) ->setBoardPHIDs(array($board_phid)) ->setObjectPHIDs($object_phids) ->executeLayout(); // TODO: This logic needs to be revised if we legitimately support // multiple column positions. // NOTE: When a task is newly created, it's implicitly added to the // backlog but we don't currently record that in the "$old_phids". Just // clean it up for now. $columns = $engine->getObjectColumns($board_phid, $object_phid); foreach ($columns as $column) { $engine->queueRemovePosition( $board_phid, $column->getPHID(), $object_phid); } // Remove all existing column positions on the board. foreach ($old_phids as $column_phid) { $engine->queueRemovePosition( $board_phid, $column_phid, $object_phid); } // Add new positions. foreach ($new_phids as $column_phid) { if ($before_phid) { $engine->queueAddPositionBefore( $board_phid, $column_phid, $object_phid, $before_phid); } else if ($after_phid) { $engine->queueAddPositionAfter( $board_phid, $column_phid, $object_phid, $after_phid); } else { $engine->queueAddPosition( $board_phid, $column_phid, $object_phid); } } $engine->applyPositionUpdates(); 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(), ManiphestTaskDependedOnByTaskEdgeType::EDGECONST); 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) ->needSubscriberPHIDs(true) ->needProjectPHIDs(true) ->execute(); $old = $unblock_xaction->getOldValue(); $new = $unblock_xaction->getNewValue(); foreach ($blocked_tasks as $blocked_task) { $parent_xaction = id(new ManiphestTransaction()) ->setTransactionType(ManiphestTransaction::TYPE_UNBLOCK) ->setOldValue(array($object->getPHID() => $old)) ->setNewValue(array($object->getPHID() => $new)); if ($this->getIsNewObject()) { $parent_xaction->setMetadataValue('blocker.new', true); } id(new ManiphestTransactionEditor()) ->setActor($this->getActor()) ->setActingAsPHID($this->getActingAsPHID()) ->setContentSource($this->getContentSource()) ->setContinueOnNoEffect(true) ->setContinueOnMissingFields(true) ->applyTransactions($blocked_task, array($parent_xaction)); } } } return $xactions; } protected function shouldSendMail( PhabricatorLiskDAO $object, array $xactions) { return true; } 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) { $phids = array(); if ($object->getOwnerPHID()) { $phids[] = $object->getOwnerPHID(); } $phids[] = $this->getActingAsPHID(); return $phids; } public function getMailTagsMap() { return array( ManiphestTransaction::MAILTAG_STATUS => pht("A task's status changes."), ManiphestTransaction::MAILTAG_OWNER => pht("A task's owner changes."), ManiphestTransaction::MAILTAG_PRIORITY => pht("A task's priority changes."), ManiphestTransaction::MAILTAG_CC => pht("A task's subscribers change."), ManiphestTransaction::MAILTAG_PROJECTS => pht("A task's associated projects change."), ManiphestTransaction::MAILTAG_UNBLOCK => pht('One of the tasks a task is blocked by changes status.'), ManiphestTransaction::MAILTAG_COLUMN => pht('A task is moved between columns on a workboard.'), ManiphestTransaction::MAILTAG_COMMENT => pht('Someone comments on a task.'), ManiphestTransaction::MAILTAG_OTHER => pht('Other task activity not listed above occurs.'), ); } 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->addRemarkupSection( pht('TASK DESCRIPTION'), $object->getDescription()); } $body->addLinkSection( pht('TASK DETAIL'), PhabricatorEnv::getProductionURI('/T'.$object->getID())); $board_phids = array(); $type_column = ManiphestTransaction::TYPE_PROJECT_COLUMN; foreach ($xactions as $xaction) { if ($xaction->getTransactionType() == $type_column) { $new = $xaction->getNewValue(); $project_phid = idx($new, 'projectPHID'); if ($project_phid) { $board_phids[] = $project_phid; } } } if ($board_phids) { $projects = id(new PhabricatorProjectQuery()) ->setViewer($this->requireActor()) ->withPHIDs($board_phids) ->execute(); foreach ($projects as $project) { $body->addLinkSection( pht('WORKBOARD'), PhabricatorEnv::getProductionURI( '/project/board/'.$project->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 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; } /** * Get priorities for moving a task to a new priority. */ public static function getEdgeSubpriority( $priority, $is_end) { $query = id(new ManiphestTaskQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withPriorities(array($priority)) ->setLimit(1); if ($is_end) { $query->setOrderVector(array('-priority', '-subpriority', '-id')); } else { $query->setOrderVector(array('priority', 'subpriority', 'id')); } $result = $query->executeOne(); $step = (double)(2 << 32); if ($result) { $base = $result->getSubpriority(); if ($is_end) { $sub = ($base - $step); } else { $sub = ($base + $step); } } else { $sub = 0; } return array($priority, $sub); } /** * Get priorities for moving a task before or after another task. */ public static function getAdjacentSubpriority( ManiphestTask $dst, $is_after, $allow_recursion = true) { $query = id(new ManiphestTaskQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->setOrder(ManiphestTaskQuery::ORDER_PRIORITY) ->withPriorities(array($dst->getPriority())) ->setLimit(1); if ($is_after) { $query->setAfterID($dst->getID()); } else { $query->setBeforeID($dst->getID()); } $adjacent = $query->executeOne(); $base = $dst->getSubpriority(); $step = (double)(2 << 32); // If we find an adjacent task, we average the two subpriorities and // return the result. if ($adjacent) { $epsilon = 0.01; // If the adjacent task has a subpriority that is identical or very // close to the task we're looking at, we're going to move it and all // tasks with the same subpriority a little farther down the subpriority // scale. if ($allow_recursion && (abs($adjacent->getSubpriority() - $base) < $epsilon)) { $conn_w = $adjacent->establishConnection('w'); $min = ($adjacent->getSubpriority() - ($epsilon)); $max = ($adjacent->getSubpriority() + ($epsilon)); // Get all of the tasks with the similar subpriorities to the adjacent // task, including the adjacent task itself. $query = id(new ManiphestTaskQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withPriorities(array($adjacent->getPriority())) ->withSubpriorityBetween($min, $max); if (!$is_after) { $query->setOrderVector(array('-priority', '-subpriority', '-id')); } else { $query->setOrderVector(array('priority', 'subpriority', 'id')); } $shift_all = $query->execute(); $shift_last = last($shift_all); // Select the most extreme subpriority in the result set as the // base value. $shift_base = head($shift_all)->getSubpriority(); // Find the subpriority before or after the task at the end of the // block. list($shift_pri, $shift_sub) = self::getAdjacentSubpriority( $shift_last, $is_after, $allow_recursion = false); $delta = ($shift_sub - $shift_base); $count = count($shift_all); $shift = array(); $cursor = 1; foreach ($shift_all as $shift_task) { $shift_target = $shift_base + (($cursor / $count) * $delta); $cursor++; queryfx( $conn_w, 'UPDATE %T SET subpriority = %f WHERE id = %d', $adjacent->getTableName(), $shift_target, $shift_task->getID()); // If we're shifting the adjacent task, update it. if ($shift_task->getID() == $adjacent->getID()) { $adjacent->setSubpriority($shift_target); } // If we're shifting the original target task, update the base // subpriority. if ($shift_task->getID() == $dst->getID()) { $base = $shift_target; } } } $sub = ($adjacent->getSubpriority() + $base) / 2; } else { // Otherwise, we take a step away from the target's subpriority and // use that. if ($is_after) { $sub = ($base - $step); } else { $sub = ($base + $step); } } return array($dst->getPriority(), $sub); } protected function validateTransaction( PhabricatorLiskDAO $object, $type, array $xactions) { $errors = parent::validateTransaction($object, $type, $xactions); switch ($type) { case ManiphestTransaction::TYPE_TITLE: $missing = $this->validateIsEmptyTextField( $object->getTitle(), $xactions); if ($missing) { $error = new PhabricatorApplicationTransactionValidationError( $type, pht('Required'), pht('Task title is required.'), nonempty(last($xactions), null)); $error->setIsMissingFieldError(true); $errors[] = $error; } break; case ManiphestTransaction::TYPE_PARENT: $with_effect = array(); foreach ($xactions as $xaction) { $task_phid = $xaction->getNewValue(); if (!$task_phid) { continue; } $with_effect[] = $xaction; $task = id(new ManiphestTaskQuery()) ->setViewer($this->getActor()) ->withPHIDs(array($task_phid)) ->executeOne(); if (!$task) { $errors[] = new PhabricatorApplicationTransactionValidationError( $type, pht('Invalid'), pht( 'Parent task identifier "%s" does not identify a visible '. 'task.', $task_phid), $xaction); } } if ($with_effect && !$this->getIsNewObject()) { $errors[] = new PhabricatorApplicationTransactionValidationError( $type, pht('Invalid'), pht( 'You can only select a parent task when creating a '. 'transaction for the first time.'), last($with_effect)); } break; case ManiphestTransaction::TYPE_COLUMN: $with_effect = array(); foreach ($xactions as $xaction) { $column_phid = $xaction->getNewValue(); if (!$column_phid) { continue; } $with_effect[] = $xaction; $column = $this->loadProjectColumn($column_phid); if (!$column) { $errors[] = new PhabricatorApplicationTransactionValidationError( $type, pht('Invalid'), pht( 'Column PHID "%s" does not identify a visible column.', $column_phid), $xaction); } } if ($with_effect && !$this->getIsNewObject()) { $errors[] = new PhabricatorApplicationTransactionValidationError( $type, pht('Invalid'), pht( 'You can only put a task into an initial column during task '. 'creation.'), last($with_effect)); } break; case ManiphestTransaction::TYPE_OWNER: foreach ($xactions as $xaction) { $old = $xaction->getOldValue(); $new = $xaction->getNewValue(); if (!strlen($new)) { continue; } if ($new === $old) { continue; } $assignee_list = id(new PhabricatorPeopleQuery()) ->setViewer($this->getActor()) ->withPHIDs(array($new)) ->execute(); if (!$assignee_list) { $errors[] = new PhabricatorApplicationTransactionValidationError( $type, pht('Invalid'), pht( 'User "%s" is not a valid user.', $new), $xaction); } } break; case ManiphestTransaction::TYPE_COVER_IMAGE: foreach ($xactions as $xaction) { $old = $xaction->getOldValue(); $new = $xaction->getNewValue(); if (!$new) { continue; } if ($new === $old) { continue; } $file = id(new PhabricatorFileQuery()) ->setViewer($this->getActor()) ->withPHIDs(array($new)) ->executeOne(); if (!$file) { $errors[] = new PhabricatorApplicationTransactionValidationError( $type, pht('Invalid'), pht('File "%s" is not valid.', $new), $xaction); continue; } if (!$file->isTransformableImage()) { $errors[] = new PhabricatorApplicationTransactionValidationError( $type, pht('Invalid'), pht('File "%s" is not a valid image file.', $new), $xaction); continue; } } break; case ManiphestTransaction::TYPE_POINTS: foreach ($xactions as $xaction) { $new = $xaction->getNewValue(); if (strlen($new) && !is_numeric($new)) { $errors[] = new PhabricatorApplicationTransactionValidationError( $type, pht('Invalid'), pht('Points value must be numeric or empty.'), $xaction); continue; } if ((double)$new < 0) { $errors[] = new PhabricatorApplicationTransactionValidationError( $type, pht('Invalid'), pht('Points value must be nonnegative.'), $xaction); continue; } } break; } return $errors; } protected function expandTransactions( PhabricatorLiskDAO $object, array $xactions) { $actor = $this->getActor(); $actor_phid = $actor->getPHID(); $results = parent::expandTransactions($object, $xactions); $is_unassigned = ($object->getOwnerPHID() === null); $any_assign = false; foreach ($xactions as $xaction) { if ($xaction->getTransactionType() == ManiphestTransaction::TYPE_OWNER) { $any_assign = true; break; } } $is_open = !$object->isClosed(); $new_status = null; foreach ($xactions as $xaction) { switch ($xaction->getTransactionType()) { case ManiphestTransaction::TYPE_STATUS: $new_status = $xaction->getNewValue(); break; } } if ($new_status === null) { $is_closing = false; } else { $is_closing = ManiphestTaskStatus::isClosedStatus($new_status); } // If the task is not assigned, not being assigned, currently open, and // being closed, try to assign the actor as the owner. if ($is_unassigned && !$any_assign && $is_open && $is_closing) { + $is_claim = ManiphestTaskStatus::isClaimStatus($new_status); + // Don't assign the actor if they aren't a real user. - if ($actor_phid) { + // Don't claim the task if the status is configured to not claim. + if ($actor_phid && $is_claim) { $results[] = id(new ManiphestTransaction()) ->setTransactionType(ManiphestTransaction::TYPE_OWNER) ->setNewValue($actor_phid); } } // Automatically subscribe the author when they create a task. if ($this->getIsNewObject()) { if ($actor_phid) { $results[] = id(new ManiphestTransaction()) ->setTransactionType(PhabricatorTransactions::TYPE_SUBSCRIBERS) ->setNewValue( array( '+' => array($actor_phid => $actor_phid), )); } } return $results; } protected function expandTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { $results = parent::expandTransaction($object, $xaction); switch ($xaction->getTransactionType()) { case ManiphestTransaction::TYPE_COLUMN: $column_phid = $xaction->getNewValue(); if (!$column_phid) { break; } // When a task is created into a column, we also generate a transaction // to actually put it in that column. $column = $this->loadProjectColumn($column_phid); $results[] = id(new ManiphestTransaction()) ->setTransactionType(ManiphestTransaction::TYPE_PROJECT_COLUMN) ->setOldValue( array( 'projectPHID' => $column->getProjectPHID(), 'columnPHIDs' => array(), )) ->setNewValue( array( 'projectPHID' => $column->getProjectPHID(), 'columnPHIDs' => array($column->getPHID()), )); break; case ManiphestTransaction::TYPE_OWNER: // When a task is reassigned, move the old owner to the subscriber // list so they're still in the loop. $owner_phid = $object->getOwnerPHID(); if ($owner_phid) { $results[] = id(new ManiphestTransaction()) ->setTransactionType(PhabricatorTransactions::TYPE_SUBSCRIBERS) ->setIgnoreOnNoEffect(true) ->setNewValue( array( '+' => array($owner_phid => $owner_phid), )); } break; } return $results; } private function loadProjectColumn($column_phid) { return id(new PhabricatorProjectColumnQuery()) ->setViewer($this->getActor()) ->withPHIDs(array($column_phid)) ->executeOne(); } protected function extractFilePHIDsFromCustomTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { $phids = parent::extractFilePHIDsFromCustomTransaction($object, $xaction); switch ($xaction->getTransactionType()) { case ManiphestTransaction::TYPE_COVER_IMAGE: $phids[] = $xaction->getNewValue(); break; } return $phids; } }