diff --git a/src/applications/herald/adapter/HeraldAdapter.php b/src/applications/herald/adapter/HeraldAdapter.php index bafad0d440..d67c69f584 100644 --- a/src/applications/herald/adapter/HeraldAdapter.php +++ b/src/applications/herald/adapter/HeraldAdapter.php @@ -1,1161 +1,1165 @@ emailPHIDs); } public function getForcedEmailPHIDs() { return array_values($this->forcedEmailPHIDs); } public function addEmailPHID($phid, $force) { $this->emailPHIDs[$phid] = $phid; if ($force) { $this->forcedEmailPHIDs[$phid] = $phid; } return $this; } public function setContentSource(PhabricatorContentSource $content_source) { $this->contentSource = $content_source; return $this; } public function getContentSource() { return $this->contentSource; } public function getIsNewObject() { if (is_bool($this->isNewObject)) { return $this->isNewObject; } throw new Exception( pht( 'You must %s to a boolean first!', 'setIsNewObject()')); } public function setIsNewObject($new) { $this->isNewObject = (bool)$new; return $this; } public function supportsApplicationEmail() { return false; } public function setApplicationEmail( PhabricatorMetaMTAApplicationEmail $email) { $this->applicationEmail = $email; return $this; } public function getApplicationEmail() { return $this->applicationEmail; } public function getPHID() { return $this->getObject()->getPHID(); } abstract public function getHeraldName(); public function getHeraldField($field_key) { return $this->requireFieldImplementation($field_key) ->getHeraldFieldValue($this->getObject()); } public function applyHeraldEffects(array $effects) { assert_instances_of($effects, 'HeraldEffect'); $result = array(); foreach ($effects as $effect) { $result[] = $this->applyStandardEffect($effect); } return $result; } public function isAvailableToUser(PhabricatorUser $viewer) { $applications = id(new PhabricatorApplicationQuery()) ->setViewer($viewer) ->withInstalled(true) ->withClasses(array($this->getAdapterApplicationClass())) ->execute(); return !empty($applications); } /** * Set the list of transactions which just took effect. * * These transactions are set by @{class:PhabricatorApplicationEditor} * automatically, before it invokes Herald. * * @param list List of transactions. * @return this */ final public function setAppliedTransactions(array $xactions) { assert_instances_of($xactions, 'PhabricatorApplicationTransaction'); $this->appliedTransactions = $xactions; return $this; } /** * Get a list of transactions which just took effect. * * When an object is edited normally, transactions are applied and then * Herald executes. You can call this method to examine the transactions * if you want to react to them. * * @return list List of transactions. */ final public function getAppliedTransactions() { return $this->appliedTransactions; } public function queueTransaction($transaction) { $this->queuedTransactions[] = $transaction; } public function getQueuedTransactions() { return $this->queuedTransactions; } public function newTransaction() { $object = $this->newObject(); if (!($object instanceof PhabricatorApplicationTransactionInterface)) { throw new Exception( pht( 'Unable to build a new transaction for adapter object; it does '. 'not implement "%s".', 'PhabricatorApplicationTransactionInterface')); } return $object->getApplicationTransactionTemplate(); } /** * NOTE: You generally should not override this; it exists to support legacy * adapters which had hard-coded content types. */ public function getAdapterContentType() { return get_class($this); } abstract public function getAdapterContentName(); abstract public function getAdapterContentDescription(); abstract public function getAdapterApplicationClass(); abstract public function getObject(); /** * Return a new characteristic object for this adapter. * * The adapter will use this object to test for interfaces, generate * transactions, and interact with custom fields. * * Adapters must return an object from this method to enable custom * field rules and various implicit actions. * * Normally, you'll return an empty version of the adapted object: * * return new ApplicationObject(); * * @return null|object Template object. */ protected function newObject() { return null; } public function supportsRuleType($rule_type) { return false; } public function canTriggerOnObject($object) { return false; } public function isTestAdapterForObject($object) { return false; } public function canCreateTestAdapterForObject($object) { return $this->isTestAdapterForObject($object); } public function newTestAdapter(PhabricatorUser $viewer, $object) { return id(clone $this) ->setObject($object); } public function getAdapterTestDescription() { return null; } public function explainValidTriggerObjects() { return pht('This adapter can not trigger on objects.'); } public function getTriggerObjectPHIDs() { return array($this->getPHID()); } public function getAdapterSortKey() { return sprintf( '%08d%s', $this->getAdapterSortOrder(), $this->getAdapterContentName()); } public function getAdapterSortOrder() { return 1000; } /* -( Fields )------------------------------------------------------------- */ private function getFieldImplementationMap() { if ($this->fieldMap === null) { // We can't use PhutilClassMapQuery here because field expansion // depends on the adapter and object. $object = $this->getObject(); $map = array(); $all = HeraldField::getAllFields(); foreach ($all as $key => $field) { $field = id(clone $field)->setAdapter($this); if (!$field->supportsObject($object)) { continue; } $subfields = $field->getFieldsForObject($object); foreach ($subfields as $subkey => $subfield) { if (isset($map[$subkey])) { throw new Exception( pht( 'Two HeraldFields (of classes "%s" and "%s") have the same '. 'field key ("%s") after expansion for an object of class '. '"%s" inside adapter "%s". Each field must have a unique '. 'field key.', get_class($subfield), get_class($map[$subkey]), $subkey, get_class($object), get_class($this))); } $subfield = id(clone $subfield)->setAdapter($this); $map[$subkey] = $subfield; } } $this->fieldMap = $map; } return $this->fieldMap; } private function getFieldImplementation($key) { return idx($this->getFieldImplementationMap(), $key); } public function getFields() { return array_keys($this->getFieldImplementationMap()); } public function getFieldNameMap() { return mpull($this->getFieldImplementationMap(), 'getHeraldFieldName'); } public function getFieldGroupKey($field_key) { $field = $this->getFieldImplementation($field_key); if (!$field) { return null; } return $field->getFieldGroupKey(); } /* -( Conditions )--------------------------------------------------------- */ public function getConditionNameMap() { return array( self::CONDITION_CONTAINS => pht('contains'), self::CONDITION_NOT_CONTAINS => pht('does not contain'), self::CONDITION_IS => pht('is'), self::CONDITION_IS_NOT => pht('is not'), self::CONDITION_IS_ANY => pht('is any of'), self::CONDITION_IS_TRUE => pht('is true'), self::CONDITION_IS_FALSE => pht('is false'), self::CONDITION_IS_NOT_ANY => pht('is not any of'), self::CONDITION_INCLUDE_ALL => pht('include all of'), self::CONDITION_INCLUDE_ANY => pht('include any of'), self::CONDITION_INCLUDE_NONE => pht('do not include'), self::CONDITION_IS_ME => pht('is myself'), self::CONDITION_IS_NOT_ME => pht('is not myself'), self::CONDITION_REGEXP => pht('matches regexp'), self::CONDITION_NOT_REGEXP => pht('does not match regexp'), self::CONDITION_RULE => pht('matches:'), self::CONDITION_NOT_RULE => pht('does not match:'), self::CONDITION_EXISTS => pht('exists'), self::CONDITION_NOT_EXISTS => pht('does not exist'), self::CONDITION_UNCONDITIONALLY => '', // don't show anything! self::CONDITION_NEVER => '', // don't show anything! self::CONDITION_REGEXP_PAIR => pht('matches regexp pair'), self::CONDITION_HAS_BIT => pht('has bit'), self::CONDITION_NOT_BIT => pht('lacks bit'), ); } public function getConditionsForField($field) { return $this->requireFieldImplementation($field) ->getHeraldFieldConditions(); } private function requireFieldImplementation($field_key) { $field = $this->getFieldImplementation($field_key); if (!$field) { throw new Exception( pht( 'No field with key "%s" is available to Herald adapter "%s".', $field_key, get_class($this))); } return $field; } public function doesConditionMatch( HeraldEngine $engine, HeraldRule $rule, HeraldCondition $condition, $field_value) { $condition_type = $condition->getFieldCondition(); $condition_value = $condition->getValue(); switch ($condition_type) { case self::CONDITION_CONTAINS: case self::CONDITION_NOT_CONTAINS: // "Contains and "does not contain" can take an array of strings, as in // "Any changed filename" for diffs. $result_if_match = ($condition_type == self::CONDITION_CONTAINS); foreach ((array)$field_value as $value) { if (stripos($value, $condition_value) !== false) { return $result_if_match; } } return !$result_if_match; case self::CONDITION_IS: return ($field_value == $condition_value); case self::CONDITION_IS_NOT: return ($field_value != $condition_value); case self::CONDITION_IS_ME: return ($field_value == $rule->getAuthorPHID()); case self::CONDITION_IS_NOT_ME: return ($field_value != $rule->getAuthorPHID()); case self::CONDITION_IS_ANY: if (!is_array($condition_value)) { throw new HeraldInvalidConditionException( pht('Expected condition value to be an array.')); } $condition_value = array_fuse($condition_value); return isset($condition_value[$field_value]); case self::CONDITION_IS_NOT_ANY: if (!is_array($condition_value)) { throw new HeraldInvalidConditionException( pht('Expected condition value to be an array.')); } $condition_value = array_fuse($condition_value); return !isset($condition_value[$field_value]); case self::CONDITION_INCLUDE_ALL: if (!is_array($field_value)) { throw new HeraldInvalidConditionException( pht('Object produced non-array value!')); } if (!is_array($condition_value)) { throw new HeraldInvalidConditionException( pht('Expected condition value to be an array.')); } $have = array_select_keys(array_fuse($field_value), $condition_value); return (count($have) == count($condition_value)); case self::CONDITION_INCLUDE_ANY: return (bool)array_select_keys( array_fuse($field_value), $condition_value); case self::CONDITION_INCLUDE_NONE: return !array_select_keys( array_fuse($field_value), $condition_value); case self::CONDITION_EXISTS: case self::CONDITION_IS_TRUE: return (bool)$field_value; case self::CONDITION_NOT_EXISTS: case self::CONDITION_IS_FALSE: return !$field_value; case self::CONDITION_UNCONDITIONALLY: return (bool)$field_value; case self::CONDITION_NEVER: return false; case self::CONDITION_REGEXP: case self::CONDITION_NOT_REGEXP: $result_if_match = ($condition_type == self::CONDITION_REGEXP); foreach ((array)$field_value as $value) { // We add the 'S' flag because we use the regexp multiple times. // It shouldn't cause any troubles if the flag is already there // - /.*/S is evaluated same as /.*/SS. $result = @preg_match($condition_value.'S', $value); if ($result === false) { throw new HeraldInvalidConditionException( pht('Regular expression is not valid!')); } if ($result) { return $result_if_match; } } return !$result_if_match; case self::CONDITION_REGEXP_PAIR: // Match a JSON-encoded pair of regular expressions against a // dictionary. The first regexp must match the dictionary key, and the // second regexp must match the dictionary value. If any key/value pair // in the dictionary matches both regexps, the condition is satisfied. $regexp_pair = null; try { $regexp_pair = phutil_json_decode($condition_value); } catch (PhutilJSONParserException $ex) { throw new HeraldInvalidConditionException( pht('Regular expression pair is not valid JSON!')); } if (count($regexp_pair) != 2) { throw new HeraldInvalidConditionException( pht('Regular expression pair is not a pair!')); } $key_regexp = array_shift($regexp_pair); $value_regexp = array_shift($regexp_pair); foreach ((array)$field_value as $key => $value) { $key_matches = @preg_match($key_regexp, $key); if ($key_matches === false) { throw new HeraldInvalidConditionException( pht('First regular expression is invalid!')); } if ($key_matches) { $value_matches = @preg_match($value_regexp, $value); if ($value_matches === false) { throw new HeraldInvalidConditionException( pht('Second regular expression is invalid!')); } if ($value_matches) { return true; } } } return false; case self::CONDITION_RULE: case self::CONDITION_NOT_RULE: $rule = $engine->getRule($condition_value); if (!$rule) { throw new HeraldInvalidConditionException( pht('Condition references a rule which does not exist!')); } $is_not = ($condition_type == self::CONDITION_NOT_RULE); $result = $engine->doesRuleMatch($rule, $this); if ($is_not) { $result = !$result; } return $result; case self::CONDITION_HAS_BIT: return (($condition_value & $field_value) === (int)$condition_value); case self::CONDITION_NOT_BIT: return (($condition_value & $field_value) !== (int)$condition_value); default: throw new HeraldInvalidConditionException( pht("Unknown condition '%s'.", $condition_type)); } } public function willSaveCondition(HeraldCondition $condition) { $condition_type = $condition->getFieldCondition(); $condition_value = $condition->getValue(); switch ($condition_type) { case self::CONDITION_REGEXP: case self::CONDITION_NOT_REGEXP: $ok = @preg_match($condition_value, ''); if ($ok === false) { throw new HeraldInvalidConditionException( pht( 'The regular expression "%s" is not valid. Regular expressions '. 'must have enclosing characters (e.g. "@/path/to/file@", not '. '"/path/to/file") and be syntactically correct.', $condition_value)); } break; case self::CONDITION_REGEXP_PAIR: $json = null; try { $json = phutil_json_decode($condition_value); } catch (PhutilJSONParserException $ex) { throw new HeraldInvalidConditionException( pht( 'The regular expression pair "%s" is not valid JSON. Enter a '. 'valid JSON array with two elements.', $condition_value)); } if (count($json) != 2) { throw new HeraldInvalidConditionException( pht( 'The regular expression pair "%s" must have exactly two '. 'elements.', $condition_value)); } $key_regexp = array_shift($json); $val_regexp = array_shift($json); $key_ok = @preg_match($key_regexp, ''); if ($key_ok === false) { throw new HeraldInvalidConditionException( pht( 'The first regexp in the regexp pair, "%s", is not a valid '. 'regexp.', $key_regexp)); } $val_ok = @preg_match($val_regexp, ''); if ($val_ok === false) { throw new HeraldInvalidConditionException( pht( 'The second regexp in the regexp pair, "%s", is not a valid '. 'regexp.', $val_regexp)); } break; case self::CONDITION_CONTAINS: case self::CONDITION_NOT_CONTAINS: case self::CONDITION_IS: case self::CONDITION_IS_NOT: case self::CONDITION_IS_ANY: case self::CONDITION_IS_NOT_ANY: case self::CONDITION_INCLUDE_ALL: case self::CONDITION_INCLUDE_ANY: case self::CONDITION_INCLUDE_NONE: case self::CONDITION_IS_ME: case self::CONDITION_IS_NOT_ME: case self::CONDITION_RULE: case self::CONDITION_NOT_RULE: case self::CONDITION_EXISTS: case self::CONDITION_NOT_EXISTS: case self::CONDITION_UNCONDITIONALLY: case self::CONDITION_NEVER: case self::CONDITION_HAS_BIT: case self::CONDITION_NOT_BIT: case self::CONDITION_IS_TRUE: case self::CONDITION_IS_FALSE: // No explicit validation for these types, although there probably // should be in some cases. break; default: throw new HeraldInvalidConditionException( pht( 'Unknown condition "%s"!', $condition_type)); } } /* -( Actions )------------------------------------------------------------ */ private function getActionImplementationMap() { if ($this->actionMap === null) { // We can't use PhutilClassMapQuery here because action expansion // depends on the adapter and object. $object = $this->getObject(); $map = array(); $all = HeraldAction::getAllActions(); foreach ($all as $key => $action) { $action = id(clone $action)->setAdapter($this); if (!$action->supportsObject($object)) { continue; } $subactions = $action->getActionsForObject($object); foreach ($subactions as $subkey => $subaction) { if (isset($map[$subkey])) { throw new Exception( pht( 'Two HeraldActions (of classes "%s" and "%s") have the same '. 'action key ("%s") after expansion for an object of class '. '"%s" inside adapter "%s". Each action must have a unique '. 'action key.', get_class($subaction), get_class($map[$subkey]), $subkey, get_class($object), get_class($this))); } $subaction = id(clone $subaction)->setAdapter($this); $map[$subkey] = $subaction; } } $this->actionMap = $map; } return $this->actionMap; } private function requireActionImplementation($action_key) { $action = $this->getActionImplementation($action_key); if (!$action) { throw new Exception( pht( 'No action with key "%s" is available to Herald adapter "%s".', $action_key, get_class($this))); } return $action; } private function getActionsForRuleType($rule_type) { $actions = $this->getActionImplementationMap(); foreach ($actions as $key => $action) { if (!$action->supportsRuleType($rule_type)) { unset($actions[$key]); } } return $actions; } public function getActionImplementation($key) { return idx($this->getActionImplementationMap(), $key); } public function getActionKeys() { return array_keys($this->getActionImplementationMap()); } public function getActionGroupKey($action_key) { $action = $this->getActionImplementation($action_key); if (!$action) { return null; } return $action->getActionGroupKey(); } public function getActions($rule_type) { $actions = array(); foreach ($this->getActionsForRuleType($rule_type) as $key => $action) { $actions[] = $key; } return $actions; } public function getActionNameMap($rule_type) { $map = array(); foreach ($this->getActionsForRuleType($rule_type) as $key => $action) { $map[$key] = $action->getHeraldActionName(); } return $map; } public function willSaveAction( HeraldRule $rule, HeraldActionRecord $action) { $impl = $this->requireActionImplementation($action->getAction()); $target = $action->getTarget(); $target = $impl->willSaveActionValue($target); $action->setTarget($target); } /* -( Values )------------------------------------------------------------- */ public function getValueTypeForFieldAndCondition($field, $condition) { return $this->requireFieldImplementation($field) ->getHeraldFieldValueType($condition); } public function getValueTypeForAction($action, $rule_type) { $impl = $this->requireActionImplementation($action); return $impl->getHeraldActionValueType(); } private function buildTokenizerFieldValue( PhabricatorTypeaheadDatasource $datasource) { $key = 'action.'.get_class($datasource); return id(new HeraldTokenizerFieldValue()) ->setKey($key) ->setDatasource($datasource); } /* -( Repetition )--------------------------------------------------------- */ public function getRepetitionOptions() { $options = array(); $options[] = HeraldRule::REPEAT_EVERY; // Some rules, like pre-commit rules, only ever fire once. It doesn't // make sense to use state-based repetition policies like "only the first // time" for these rules. if (!$this->isSingleEventAdapter()) { $options[] = HeraldRule::REPEAT_FIRST; + $options[] = HeraldRule::REPEAT_CHANGE; } return $options; } protected function initializeNewAdapter() { $this->setObject($this->newObject()); return $this; } /** * Does this adapter's event fire only once? * * Single use adapters (like pre-commit and diff adapters) only fire once, * so fields like "Is new object" don't make sense to apply to their content. * * @return bool */ public function isSingleEventAdapter() { return false; } public static function getAllAdapters() { return id(new PhutilClassMapQuery()) ->setAncestorClass(__CLASS__) ->setUniqueMethod('getAdapterContentType') ->setSortMethod('getAdapterSortKey') ->execute(); } public static function getAdapterForContentType($content_type) { $adapters = self::getAllAdapters(); foreach ($adapters as $adapter) { if ($adapter->getAdapterContentType() == $content_type) { $adapter = id(clone $adapter); $adapter->initializeNewAdapter(); return $adapter; } } throw new Exception( pht( 'No adapter exists for Herald content type "%s".', $content_type)); } public static function getEnabledAdapterMap(PhabricatorUser $viewer) { $map = array(); $adapters = self::getAllAdapters(); foreach ($adapters as $adapter) { if (!$adapter->isAvailableToUser($viewer)) { continue; } $type = $adapter->getAdapterContentType(); $name = $adapter->getAdapterContentName(); $map[$type] = $name; } return $map; } public function getEditorValueForCondition( PhabricatorUser $viewer, HeraldCondition $condition) { $field = $this->requireFieldImplementation($condition->getFieldName()); return $field->getEditorValue( $viewer, $condition->getFieldCondition(), $condition->getValue()); } public function getEditorValueForAction( PhabricatorUser $viewer, HeraldActionRecord $action_record) { $action = $this->requireActionImplementation($action_record->getAction()); return $action->getEditorValue( $viewer, $action_record->getTarget()); } public function renderRuleAsText( HeraldRule $rule, PhabricatorHandleList $handles, PhabricatorUser $viewer) { require_celerity_resource('herald-css'); $icon = id(new PHUIIconView()) ->setIcon('fa-chevron-circle-right lightgreytext') ->addClass('herald-list-icon'); if ($rule->getMustMatchAll()) { $match_text = pht('When all of these conditions are met:'); } else { $match_text = pht('When any of these conditions are met:'); } $match_title = phutil_tag( 'p', array( 'class' => 'herald-list-description', ), $match_text); $match_list = array(); foreach ($rule->getConditions() as $condition) { $match_list[] = phutil_tag( 'div', array( 'class' => 'herald-list-item', ), array( $icon, $this->renderConditionAsText($condition, $handles, $viewer), )); } - if ($rule->isRepeatEvery()) { - $action_text = - pht('Take these actions every time this rule matches:'); + if ($rule->isRepeatFirst()) { + $action_text = pht( + 'Take these actions the first time this rule matches:'); + } else if ($rule->isRepeatOnChange()) { + $action_text = pht( + 'Take these actions if this rule did not match the last time:'); } else { - $action_text = - pht('Take these actions the first time this rule matches:'); + $action_text = pht( + 'Take these actions every time this rule matches:'); } $action_title = phutil_tag( 'p', array( 'class' => 'herald-list-description', ), $action_text); $action_list = array(); foreach ($rule->getActions() as $action) { $action_list[] = phutil_tag( 'div', array( 'class' => 'herald-list-item', ), array( $icon, $this->renderActionAsText($viewer, $action, $handles), )); } return array( $match_title, $match_list, $action_title, $action_list, ); } private function renderConditionAsText( HeraldCondition $condition, PhabricatorHandleList $handles, PhabricatorUser $viewer) { $field_type = $condition->getFieldName(); $field = $this->getFieldImplementation($field_type); if (!$field) { return pht('Unknown Field: "%s"', $field_type); } $field_name = $field->getHeraldFieldName(); $condition_type = $condition->getFieldCondition(); $condition_name = idx($this->getConditionNameMap(), $condition_type); $value = $this->renderConditionValueAsText($condition, $handles, $viewer); return array( $field_name, ' ', $condition_name, ' ', $value, ); } private function renderActionAsText( PhabricatorUser $viewer, HeraldActionRecord $action, PhabricatorHandleList $handles) { $impl = $this->getActionImplementation($action->getAction()); if ($impl) { $impl->setViewer($viewer); $value = $action->getTarget(); return $impl->renderActionDescription($value); } $rule_global = HeraldRuleTypeConfig::RULE_TYPE_GLOBAL; $action_type = $action->getAction(); $default = pht('(Unknown Action "%s") equals', $action_type); $action_name = idx( $this->getActionNameMap($rule_global), $action_type, $default); $target = $this->renderActionTargetAsText($action, $handles); return hsprintf(' %s %s', $action_name, $target); } private function renderConditionValueAsText( HeraldCondition $condition, PhabricatorHandleList $handles, PhabricatorUser $viewer) { $field = $this->requireFieldImplementation($condition->getFieldName()); return $field->renderConditionValue( $viewer, $condition->getFieldCondition(), $condition->getValue()); } private function renderActionTargetAsText( HeraldActionRecord $action, PhabricatorHandleList $handles) { // TODO: This should be driven through HeraldAction. $target = $action->getTarget(); if (!is_array($target)) { $target = array($target); } foreach ($target as $index => $val) { switch ($action->getAction()) { default: $handle = $handles->getHandleIfExists($val); if ($handle) { $target[$index] = $handle->renderLink(); } break; } } $target = phutil_implode_html(', ', $target); return $target; } /** * Given a @{class:HeraldRule}, this function extracts all the phids that * we'll want to load as handles later. * * This function performs a somewhat hacky approach to figuring out what * is and is not a phid - try to get the phid type and if the type is * *not* unknown assume its a valid phid. * * Don't try this at home. Use more strongly typed data at home. * * Think of the children. */ public static function getHandlePHIDs(HeraldRule $rule) { $phids = array($rule->getAuthorPHID()); foreach ($rule->getConditions() as $condition) { $value = $condition->getValue(); if (!is_array($value)) { $value = array($value); } foreach ($value as $val) { if (phid_get_type($val) != PhabricatorPHIDConstants::PHID_TYPE_UNKNOWN) { $phids[] = $val; } } } foreach ($rule->getActions() as $action) { $target = $action->getTarget(); if (!is_array($target)) { $target = array($target); } foreach ($target as $val) { if (phid_get_type($val) != PhabricatorPHIDConstants::PHID_TYPE_UNKNOWN) { $phids[] = $val; } } } if ($rule->isObjectRule()) { $phids[] = $rule->getTriggerObjectPHID(); } return $phids; } /* -( Applying Effects )--------------------------------------------------- */ /** * @task apply */ protected function applyStandardEffect(HeraldEffect $effect) { $action = $effect->getAction(); $rule_type = $effect->getRule()->getRuleType(); $impl = $this->getActionImplementation($action); if (!$impl) { return new HeraldApplyTranscript( $effect, false, array( array( HeraldAction::DO_STANDARD_INVALID_ACTION, $action, ), )); } if (!$impl->supportsRuleType($rule_type)) { return new HeraldApplyTranscript( $effect, false, array( array( HeraldAction::DO_STANDARD_WRONG_RULE_TYPE, $rule_type, ), )); } $impl->applyEffect($this->getObject(), $effect); return $impl->getApplyTranscript($effect); } public function loadEdgePHIDs($type) { if (!isset($this->edgeCache[$type])) { $phids = PhabricatorEdgeQuery::loadDestinationPHIDs( $this->getObject()->getPHID(), $type); $this->edgeCache[$type] = array_fuse($phids); } return $this->edgeCache[$type]; } /* -( Forbidden Actions )-------------------------------------------------- */ final public function getForbiddenActions() { return array_keys($this->forbiddenActions); } final public function setForbiddenAction($action, $reason) { $this->forbiddenActions[$action] = $reason; return $this; } final public function getRequiredFieldStates($field_key) { return $this->requireFieldImplementation($field_key) ->getRequiredAdapterStates(); } final public function getRequiredActionStates($action_key) { return $this->requireActionImplementation($action_key) ->getRequiredAdapterStates(); } final public function getForbiddenReason($action) { if (!isset($this->forbiddenActions[$action])) { throw new Exception( pht( 'Action "%s" is not forbidden!', $action)); } return $this->forbiddenActions[$action]; } } diff --git a/src/applications/herald/controller/HeraldRuleController.php b/src/applications/herald/controller/HeraldRuleController.php index 906afe835e..c61c29e90e 100644 --- a/src/applications/herald/controller/HeraldRuleController.php +++ b/src/applications/herald/controller/HeraldRuleController.php @@ -1,724 +1,724 @@ getViewer(); $id = $request->getURIData('id'); $content_type_map = HeraldAdapter::getEnabledAdapterMap($viewer); $rule_type_map = HeraldRuleTypeConfig::getRuleTypeMap(); if ($id) { $rule = id(new HeraldRuleQuery()) ->setViewer($viewer) ->withIDs(array($id)) ->requireCapabilities( array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_EDIT, )) ->executeOne(); if (!$rule) { return new Aphront404Response(); } $cancel_uri = '/'.$rule->getMonogram(); } else { $new_uri = $this->getApplicationURI('new/'); $rule = new HeraldRule(); $rule->setAuthorPHID($viewer->getPHID()); $rule->setMustMatchAll(1); $content_type = $request->getStr('content_type'); $rule->setContentType($content_type); $rule_type = $request->getStr('rule_type'); if (!isset($rule_type_map[$rule_type])) { return $this->newDialog() ->setTitle(pht('Invalid Rule Type')) ->appendParagraph( pht( 'The selected rule type ("%s") is not recognized by Herald.', $rule_type)) ->addCancelButton($new_uri); } $rule->setRuleType($rule_type); try { $adapter = HeraldAdapter::getAdapterForContentType( $rule->getContentType()); } catch (Exception $ex) { return $this->newDialog() ->setTitle(pht('Invalid Content Type')) ->appendParagraph( pht( 'The selected content type ("%s") is not recognized by '. 'Herald.', $rule->getContentType())) ->addCancelButton($new_uri); } if (!$adapter->supportsRuleType($rule->getRuleType())) { return $this->newDialog() ->setTitle(pht('Rule/Content Mismatch')) ->appendParagraph( pht( 'The selected rule type ("%s") is not supported by the selected '. 'content type ("%s").', $rule->getRuleType(), $rule->getContentType())) ->addCancelButton($new_uri); } if ($rule->isObjectRule()) { $rule->setTriggerObjectPHID($request->getStr('targetPHID')); $object = id(new PhabricatorObjectQuery()) ->setViewer($viewer) ->withPHIDs(array($rule->getTriggerObjectPHID())) ->requireCapabilities( array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_EDIT, )) ->executeOne(); if (!$object) { throw new Exception( pht('No valid object provided for object rule!')); } if (!$adapter->canTriggerOnObject($object)) { throw new Exception( pht('Object is of wrong type for adapter!')); } } $cancel_uri = $this->getApplicationURI(); } if ($rule->isGlobalRule()) { $this->requireApplicationCapability( HeraldManageGlobalRulesCapability::CAPABILITY); } $adapter = HeraldAdapter::getAdapterForContentType($rule->getContentType()); $local_version = id(new HeraldRule())->getConfigVersion(); if ($rule->getConfigVersion() > $local_version) { throw new Exception( pht( 'This rule was created with a newer version of Herald. You can not '. 'view or edit it in this older version. Upgrade your Phabricator '. 'deployment.')); } // Upgrade rule version to our version, since we might add newly-defined // conditions, etc. $rule->setConfigVersion($local_version); $rule_conditions = $rule->loadConditions(); $rule_actions = $rule->loadActions(); $rule->attachConditions($rule_conditions); $rule->attachActions($rule_actions); $e_name = true; $errors = array(); if ($request->isFormPost() && $request->getStr('save')) { list($e_name, $errors) = $this->saveRule($adapter, $rule, $request); if (!$errors) { $id = $rule->getID(); $uri = '/'.$rule->getMonogram(); return id(new AphrontRedirectResponse())->setURI($uri); } } $must_match_selector = $this->renderMustMatchSelector($rule); $repetition_selector = $this->renderRepetitionSelector($rule, $adapter); $handles = $this->loadHandlesForRule($rule); require_celerity_resource('herald-css'); $content_type_name = $content_type_map[$rule->getContentType()]; $rule_type_name = $rule_type_map[$rule->getRuleType()]; $form = id(new AphrontFormView()) ->setUser($viewer) ->setID('herald-rule-edit-form') ->addHiddenInput('content_type', $rule->getContentType()) ->addHiddenInput('rule_type', $rule->getRuleType()) ->addHiddenInput('save', 1) ->appendChild( // Build this explicitly (instead of using addHiddenInput()) // so we can add a sigil to it. javelin_tag( 'input', array( 'type' => 'hidden', 'name' => 'rule', 'sigil' => 'rule', ))) ->appendChild( id(new AphrontFormTextControl()) ->setLabel(pht('Rule Name')) ->setName('name') ->setError($e_name) ->setValue($rule->getName())); $trigger_object_control = false; if ($rule->isObjectRule()) { $trigger_object_control = id(new AphrontFormStaticControl()) ->setValue( pht( 'This rule triggers for %s.', $handles[$rule->getTriggerObjectPHID()]->renderLink())); } $form ->appendChild( id(new AphrontFormMarkupControl()) ->setValue(pht( 'This %s rule triggers for %s.', phutil_tag('strong', array(), $rule_type_name), phutil_tag('strong', array(), $content_type_name)))) ->appendChild($trigger_object_control) ->appendChild( id(new PHUIFormInsetView()) ->setTitle(pht('Conditions')) ->setRightButton(javelin_tag( 'a', array( 'href' => '#', 'class' => 'button button-green', 'sigil' => 'create-condition', 'mustcapture' => true, ), pht('New Condition'))) ->setDescription( pht('When %s these conditions are met:', $must_match_selector)) ->setContent(javelin_tag( 'table', array( 'sigil' => 'rule-conditions', 'class' => 'herald-condition-table', ), ''))) ->appendChild( id(new PHUIFormInsetView()) ->setTitle(pht('Action')) ->setRightButton(javelin_tag( 'a', array( 'href' => '#', 'class' => 'button button-green', 'sigil' => 'create-action', 'mustcapture' => true, ), pht('New Action'))) ->setDescription(pht( - 'Take these actions %s this rule matches:', + 'Take these actions %s', $repetition_selector)) ->setContent(javelin_tag( 'table', array( 'sigil' => 'rule-actions', 'class' => 'herald-action-table', ), ''))) ->appendChild( id(new AphrontFormSubmitControl()) ->setValue(pht('Save Rule')) ->addCancelButton($cancel_uri)); $this->setupEditorBehavior($rule, $handles, $adapter); $title = $rule->getID() ? pht('Edit Herald Rule: %s', $rule->getName()) : pht('Create Herald Rule: %s', idx($content_type_map, $content_type)); $form_box = id(new PHUIObjectBoxView()) ->setHeaderText($title) ->setBackground(PHUIObjectBoxView::WHITE_CONFIG) ->setFormErrors($errors) ->setForm($form); $crumbs = $this ->buildApplicationCrumbs() ->addTextCrumb($title) ->setBorder(true); $view = id(new PHUITwoColumnView()) ->setFooter($form_box); return $this->newPage() ->setTitle($title) ->setCrumbs($crumbs) ->appendChild( array( $view, )); } private function saveRule(HeraldAdapter $adapter, $rule, $request) { $new_name = $request->getStr('name'); $match_all = ($request->getStr('must_match') == 'all'); $repetition_policy_param = $request->getStr('repetition_policy'); $e_name = true; $errors = array(); if (!strlen($new_name)) { $e_name = pht('Required'); $errors[] = pht('Rule must have a name.'); } $data = null; try { $data = phutil_json_decode($request->getStr('rule')); } catch (PhutilJSONParserException $ex) { throw new PhutilProxyException( pht('Failed to decode rule data.'), $ex); } if (!is_array($data) || !$data['conditions'] || !$data['actions']) { throw new Exception(pht('Failed to decode rule data.')); } $conditions = array(); foreach ($data['conditions'] as $condition) { if ($condition === null) { // We manage this as a sparse array on the client, so may receive // NULL if conditions have been removed. continue; } $obj = new HeraldCondition(); $obj->setFieldName($condition[0]); $obj->setFieldCondition($condition[1]); if (is_array($condition[2])) { $obj->setValue(array_keys($condition[2])); } else { $obj->setValue($condition[2]); } try { $adapter->willSaveCondition($obj); } catch (HeraldInvalidConditionException $ex) { $errors[] = $ex->getMessage(); } $conditions[] = $obj; } $actions = array(); foreach ($data['actions'] as $action) { if ($action === null) { // Sparse on the client; removals can give us NULLs. continue; } if (!isset($action[1])) { // Legitimate for any action which doesn't need a target, like // "Do nothing". $action[1] = null; } $obj = new HeraldActionRecord(); $obj->setAction($action[0]); $obj->setTarget($action[1]); try { $adapter->willSaveAction($rule, $obj); } catch (HeraldInvalidActionException $ex) { $errors[] = $ex->getMessage(); } $actions[] = $obj; } if (!$errors) { $new_state = id(new HeraldRuleSerializer())->serializeRuleComponents( $match_all, $conditions, $actions, $repetition_policy_param); $xactions = array(); $xactions[] = id(new HeraldRuleTransaction()) ->setTransactionType(HeraldRuleTransaction::TYPE_EDIT) ->setNewValue($new_state); $xactions[] = id(new HeraldRuleTransaction()) ->setTransactionType(HeraldRuleTransaction::TYPE_NAME) ->setNewValue($new_name); try { id(new HeraldRuleEditor()) ->setActor($this->getViewer()) ->setContinueOnNoEffect(true) ->setContentSourceFromRequest($request) ->applyTransactions($rule, $xactions); return array(null, null); } catch (Exception $ex) { $errors[] = $ex->getMessage(); } } // mutate current rule, so it would be sent to the client in the right state $rule->setMustMatchAll((int)$match_all); $rule->setName($new_name); $rule->setRepetitionPolicyStringConstant($repetition_policy_param); $rule->attachConditions($conditions); $rule->attachActions($actions); return array($e_name, $errors); } private function setupEditorBehavior( HeraldRule $rule, array $handles, HeraldAdapter $adapter) { $all_rules = $this->loadRulesThisRuleMayDependUpon($rule); $all_rules = mpull($all_rules, 'getName', 'getPHID'); asort($all_rules); $all_fields = $adapter->getFieldNameMap(); $all_conditions = $adapter->getConditionNameMap(); $all_actions = $adapter->getActionNameMap($rule->getRuleType()); $fields = $adapter->getFields(); $field_map = array_select_keys($all_fields, $fields); // Populate any fields which exist in the rule but which we don't know the // names of, so that saving a rule without touching anything doesn't change // it. foreach ($rule->getConditions() as $condition) { $field_name = $condition->getFieldName(); if (empty($field_map[$field_name])) { $field_map[$field_name] = pht('', $field_name); } } $actions = $adapter->getActions($rule->getRuleType()); $action_map = array_select_keys($all_actions, $actions); // Populate any actions which exist in the rule but which we don't know the // names of, so that saving a rule without touching anything doesn't change // it. foreach ($rule->getActions() as $action) { $action_name = $action->getAction(); if (empty($action_map[$action_name])) { $action_map[$action_name] = pht('', $action_name); } } $config_info = array(); $config_info['fields'] = $this->getFieldGroups($adapter, $field_map); $config_info['conditions'] = $all_conditions; $config_info['actions'] = $this->getActionGroups($adapter, $action_map); $config_info['valueMap'] = array(); foreach ($field_map as $field => $name) { try { $field_conditions = $adapter->getConditionsForField($field); } catch (Exception $ex) { $field_conditions = array(HeraldAdapter::CONDITION_UNCONDITIONALLY); } $config_info['conditionMap'][$field] = $field_conditions; } foreach ($field_map as $field => $fname) { foreach ($config_info['conditionMap'][$field] as $condition) { $value_key = $adapter->getValueTypeForFieldAndCondition( $field, $condition); if ($value_key instanceof HeraldFieldValue) { $value_key->setViewer($this->getViewer()); $spec = $value_key->getControlSpecificationDictionary(); $value_key = $value_key->getFieldValueKey(); $config_info['valueMap'][$value_key] = $spec; } $config_info['values'][$field][$condition] = $value_key; } } $config_info['rule_type'] = $rule->getRuleType(); foreach ($action_map as $action => $name) { try { $value_key = $adapter->getValueTypeForAction( $action, $rule->getRuleType()); } catch (Exception $ex) { $value_key = new HeraldEmptyFieldValue(); } if ($value_key instanceof HeraldFieldValue) { $value_key->setViewer($this->getViewer()); $spec = $value_key->getControlSpecificationDictionary(); $value_key = $value_key->getFieldValueKey(); $config_info['valueMap'][$value_key] = $spec; } $config_info['targets'][$action] = $value_key; } $default_group = head($config_info['fields']); $default_field = head_key($default_group['options']); $default_condition = head($config_info['conditionMap'][$default_field]); $default_actions = head($config_info['actions']); $default_action = head_key($default_actions['options']); if ($rule->getConditions()) { $serial_conditions = array(); foreach ($rule->getConditions() as $condition) { $value = $adapter->getEditorValueForCondition( $this->getViewer(), $condition); $serial_conditions[] = array( $condition->getFieldName(), $condition->getFieldCondition(), $value, ); } } else { $serial_conditions = array( array($default_field, $default_condition, null), ); } if ($rule->getActions()) { $serial_actions = array(); foreach ($rule->getActions() as $action) { $value = $adapter->getEditorValueForAction( $this->getViewer(), $action); $serial_actions[] = array( $action->getAction(), $value, ); } } else { $serial_actions = array( array($default_action, null), ); } Javelin::initBehavior( 'herald-rule-editor', array( 'root' => 'herald-rule-edit-form', 'default' => array( 'field' => $default_field, 'condition' => $default_condition, 'action' => $default_action, ), 'conditions' => (object)$serial_conditions, 'actions' => (object)$serial_actions, 'template' => $this->buildTokenizerTemplates() + array( 'rules' => $all_rules, ), 'info' => $config_info, )); } private function loadHandlesForRule($rule) { $phids = array(); foreach ($rule->getActions() as $action) { if (!is_array($action->getTarget())) { continue; } foreach ($action->getTarget() as $target) { $target = (array)$target; foreach ($target as $phid) { $phids[] = $phid; } } } foreach ($rule->getConditions() as $condition) { $value = $condition->getValue(); if (is_array($value)) { foreach ($value as $phid) { $phids[] = $phid; } } } $phids[] = $rule->getAuthorPHID(); if ($rule->isObjectRule()) { $phids[] = $rule->getTriggerObjectPHID(); } return $this->loadViewerHandles($phids); } /** * Render the selector for the "When (all of | any of) these conditions are * met:" element. */ private function renderMustMatchSelector($rule) { return AphrontFormSelectControl::renderSelectTag( $rule->getMustMatchAll() ? 'all' : 'any', array( 'all' => pht('all of'), 'any' => pht('any of'), ), array( 'name' => 'must_match', )); } /** * Render the selector for "Take these actions (every time | only the first * time) this rule matches..." element. */ private function renderRepetitionSelector($rule, HeraldAdapter $adapter) { $repetition_policy = $rule->getRepetitionPolicyStringConstant(); $repetition_options = $adapter->getRepetitionOptions(); $repetition_names = HeraldRule::getRepetitionPolicySelectOptionMap(); $repetition_map = array_select_keys($repetition_names, $repetition_options); if (count($repetition_map) < 2) { return head($repetition_names); } else { return AphrontFormSelectControl::renderSelectTag( $repetition_policy, $repetition_map, array( 'name' => 'repetition_policy', )); } } protected function buildTokenizerTemplates() { $template = new AphrontTokenizerTemplateView(); $template = $template->render(); return array( 'markup' => $template, ); } /** * Load rules for the "Another Herald rule..." condition dropdown, which * allows one rule to depend upon the success or failure of another rule. */ private function loadRulesThisRuleMayDependUpon(HeraldRule $rule) { $viewer = $this->getRequest()->getUser(); // Any rule can depend on a global rule. $all_rules = id(new HeraldRuleQuery()) ->setViewer($viewer) ->withRuleTypes(array(HeraldRuleTypeConfig::RULE_TYPE_GLOBAL)) ->withContentTypes(array($rule->getContentType())) ->execute(); if ($rule->isObjectRule()) { // Object rules may depend on other rules for the same object. $all_rules += id(new HeraldRuleQuery()) ->setViewer($viewer) ->withRuleTypes(array(HeraldRuleTypeConfig::RULE_TYPE_OBJECT)) ->withContentTypes(array($rule->getContentType())) ->withTriggerObjectPHIDs(array($rule->getTriggerObjectPHID())) ->execute(); } if ($rule->isPersonalRule()) { // Personal rules may depend upon your other personal rules. $all_rules += id(new HeraldRuleQuery()) ->setViewer($viewer) ->withRuleTypes(array(HeraldRuleTypeConfig::RULE_TYPE_PERSONAL)) ->withContentTypes(array($rule->getContentType())) ->withAuthorPHIDs(array($rule->getAuthorPHID())) ->execute(); } // mark disabled rules as disabled since they are not useful as such; // don't filter though to keep edit cases sane / expected foreach ($all_rules as $current_rule) { if ($current_rule->getIsDisabled()) { $current_rule->makeEphemeral(); $current_rule->setName($rule->getName().' '.pht('(Disabled)')); } } // A rule can not depend upon itself. unset($all_rules[$rule->getID()]); return $all_rules; } private function getFieldGroups(HeraldAdapter $adapter, array $field_map) { $group_map = array(); foreach ($field_map as $field_key => $field_name) { $group_key = $adapter->getFieldGroupKey($field_key); $group_map[$group_key][$field_key] = $field_name; } return $this->getGroups( $group_map, HeraldFieldGroup::getAllFieldGroups()); } private function getActionGroups(HeraldAdapter $adapter, array $action_map) { $group_map = array(); foreach ($action_map as $action_key => $action_name) { $group_key = $adapter->getActionGroupKey($action_key); $group_map[$group_key][$action_key] = $action_name; } return $this->getGroups( $group_map, HeraldActionGroup::getAllActionGroups()); } private function getGroups(array $item_map, array $group_list) { assert_instances_of($group_list, 'HeraldGroup'); $groups = array(); foreach ($item_map as $group_key => $options) { asort($options); $group_object = idx($group_list, $group_key); if ($group_object) { $group_label = $group_object->getGroupLabel(); $group_order = $group_object->getSortKey(); } else { $group_label = nonempty($group_key, pht('Other')); $group_order = 'Z'; } $groups[] = array( 'label' => $group_label, 'options' => $options, 'order' => $group_order, ); } return array_values(isort($groups, 'order')); } } diff --git a/src/applications/herald/engine/HeraldEngine.php b/src/applications/herald/engine/HeraldEngine.php index afeba459ff..739e83e4e8 100644 --- a/src/applications/herald/engine/HeraldEngine.php +++ b/src/applications/herald/engine/HeraldEngine.php @@ -1,567 +1,633 @@ dryRun = $dry_run; return $this; } public function getDryRun() { return $this->dryRun; } public function getRule($phid) { return idx($this->rules, $phid); } public function loadRulesForAdapter(HeraldAdapter $adapter) { return id(new HeraldRuleQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withDisabled(false) ->withContentTypes(array($adapter->getAdapterContentType())) ->needConditionsAndActions(true) ->needAppliedToPHIDs(array($adapter->getPHID())) ->needValidateAuthors(true) ->execute(); } public static function loadAndApplyRules(HeraldAdapter $adapter) { $engine = new HeraldEngine(); $rules = $engine->loadRulesForAdapter($adapter); $effects = $engine->applyRules($rules, $adapter); $engine->applyEffects($effects, $adapter, $rules); return $engine->getTranscript(); } public function applyRules(array $rules, HeraldAdapter $object) { assert_instances_of($rules, 'HeraldRule'); $t_start = microtime(true); // Rules execute in a well-defined order: sort them into execution order. $rules = msort($rules, 'getRuleExecutionOrderSortKey'); $rules = mpull($rules, null, 'getPHID'); $this->transcript = new HeraldTranscript(); $this->transcript->setObjectPHID((string)$object->getPHID()); $this->fieldCache = array(); $this->results = array(); $this->rules = $rules; $this->object = $object; $effects = array(); foreach ($rules as $phid => $rule) { $this->stack = array(); $is_first_only = $rule->isRepeatFirst(); try { if (!$this->getDryRun() && $is_first_only && $rule->getRuleApplied($object->getPHID())) { // This is not a dry run, and this rule is only supposed to be // applied a single time, and it's already been applied... // That means automatic failure. $this->newRuleTranscript($rule) ->setResult(false) ->setReason( pht( 'This rule is only supposed to be repeated a single time, '. 'and it has already been applied.')); $rule_matches = false; } else { if ($this->isForbidden($rule, $object)) { $this->newRuleTranscript($rule) ->setResult(HeraldRuleTranscript::RESULT_FORBIDDEN) ->setReason( pht( 'Object state is not compatible with rule.')); $rule_matches = false; } else { $rule_matches = $this->doesRuleMatch($rule, $object); } } } catch (HeraldRecursiveConditionsException $ex) { $names = array(); foreach ($this->stack as $rule_phid => $ignored) { $names[] = '"'.$rules[$rule_phid]->getName().'"'; } $names = implode(', ', $names); foreach ($this->stack as $rule_phid => $ignored) { $this->newRuleTranscript($rules[$rule_phid]) ->setResult(false) ->setReason( pht( "Rules %s are recursively dependent upon one another! ". "Don't do this! You have formed an unresolvable cycle in the ". "dependency graph!", $names)); } $rule_matches = false; } $this->results[$phid] = $rule_matches; if ($rule_matches) { foreach ($this->getRuleEffects($rule, $object) as $effect) { $effects[] = $effect; } } } $object_transcript = new HeraldObjectTranscript(); $object_transcript->setPHID($object->getPHID()); $object_transcript->setName($object->getHeraldName()); $object_transcript->setType($object->getAdapterContentType()); $object_transcript->setFields($this->fieldCache); $this->transcript->setObjectTranscript($object_transcript); $t_end = microtime(true); $this->transcript->setDuration($t_end - $t_start); return $effects; } public function applyEffects( array $effects, HeraldAdapter $adapter, array $rules) { assert_instances_of($effects, 'HeraldEffect'); assert_instances_of($rules, 'HeraldRule'); $this->transcript->setDryRun((int)$this->getDryRun()); if ($this->getDryRun()) { $xscripts = array(); foreach ($effects as $effect) { $xscripts[] = new HeraldApplyTranscript( $effect, false, pht('This was a dry run, so no actions were actually taken.')); } } else { $xscripts = $adapter->applyHeraldEffects($effects); } assert_instances_of($xscripts, 'HeraldApplyTranscript'); foreach ($xscripts as $apply_xscript) { $this->transcript->addApplyTranscript($apply_xscript); } // For dry runs, don't mark the rule as having applied to the object. if ($this->getDryRun()) { return; } - $rules = mpull($rules, null, 'getID'); - $applied_ids = array(); + // Update the "applied" state table. How this table works depends on the + // repetition policy for the rule. + // + // REPEAT_EVERY: We delete existing rows for the rule, then write nothing. + // This policy doesn't use any state. + // + // REPEAT_FIRST: We keep existing rows, then write additional rows for + // rules which fired. This policy accumulates state over the life of the + // object. + // + // REPEAT_CHANGE: We delete existing rows, then write all the rows which + // matched. This policy only uses the state from the previous run. - // Mark all the rules that have had their effects applied as having been - // executed for the current object. + $rules = mpull($rules, null, 'getID'); $rule_ids = mpull($xscripts, 'getRuleID'); + $delete_ids = array(); + foreach ($rules as $rule_id => $rule) { + if ($rule->isRepeatFirst()) { + continue; + } + $delete_ids[] = $rule_id; + } + + $applied_ids = array(); foreach ($rule_ids as $rule_id) { if (!$rule_id) { // Some apply transcripts are purely informational and not associated // with a rule, e.g. carryover emails from earlier revisions. continue; } $rule = idx($rules, $rule_id); if (!$rule) { continue; } - if ($rule->isRepeatFirst()) { + if ($rule->isRepeatFirst() || $rule->isRepeatOnChange()) { $applied_ids[] = $rule_id; } } - if ($applied_ids) { + // Also include "only if this rule did not match the last time" rules + // which matched but were skipped in the "applied" list. + foreach ($this->skipEffects as $rule_id => $ignored) { + $applied_ids[] = $rule_id; + } + + if ($delete_ids || $applied_ids) { $conn_w = id(new HeraldRule())->establishConnection('w'); - $sql = array(); - foreach ($applied_ids as $id) { - $sql[] = qsprintf( + + if ($delete_ids) { + queryfx( $conn_w, - '(%s, %d)', + 'DELETE FROM %T WHERE phid = %s AND ruleID IN (%Ld)', + HeraldRule::TABLE_RULE_APPLIED, $adapter->getPHID(), - $id); + $delete_ids); + } + + if ($applied_ids) { + $sql = array(); + foreach ($applied_ids as $id) { + $sql[] = qsprintf( + $conn_w, + '(%s, %d)', + $adapter->getPHID(), + $id); + } + queryfx( + $conn_w, + 'INSERT IGNORE INTO %T (phid, ruleID) VALUES %Q', + HeraldRule::TABLE_RULE_APPLIED, + implode(', ', $sql)); } - queryfx( - $conn_w, - 'INSERT IGNORE INTO %T (phid, ruleID) VALUES %Q', - HeraldRule::TABLE_RULE_APPLIED, - implode(', ', $sql)); } } public function getTranscript() { $this->transcript->save(); return $this->transcript; } public function doesRuleMatch( HeraldRule $rule, HeraldAdapter $object) { $phid = $rule->getPHID(); if (isset($this->results[$phid])) { // If we've already evaluated this rule because another rule depends // on it, we don't need to reevaluate it. return $this->results[$phid]; } if (isset($this->stack[$phid])) { // We've recursed, fail all of the rules on the stack. This happens when // there's a dependency cycle with "Rule conditions match for rule ..." // conditions. foreach ($this->stack as $rule_phid => $ignored) { $this->results[$rule_phid] = false; } throw new HeraldRecursiveConditionsException(); } $this->stack[$phid] = true; $all = $rule->getMustMatchAll(); $conditions = $rule->getConditions(); $result = null; $local_version = id(new HeraldRule())->getConfigVersion(); if ($rule->getConfigVersion() > $local_version) { $reason = pht( 'Rule could not be processed, it was created with a newer version '. 'of Herald.'); $result = false; } else if (!$conditions) { $reason = pht( 'Rule failed automatically because it has no conditions.'); $result = false; } else if (!$rule->hasValidAuthor()) { $reason = pht( 'Rule failed automatically because its owner is invalid '. 'or disabled.'); $result = false; } else if (!$this->canAuthorViewObject($rule, $object)) { $reason = pht( 'Rule failed automatically because it is a personal rule and its '. 'owner can not see the object.'); $result = false; } else if (!$this->canRuleApplyToObject($rule, $object)) { $reason = pht( 'Rule failed automatically because it is an object rule which is '. 'not relevant for this object.'); $result = false; } else { foreach ($conditions as $condition) { try { $this->getConditionObjectValue($condition, $object); } catch (Exception $ex) { $reason = pht( 'Field "%s" does not exist!', $condition->getFieldName()); $result = false; break; } $match = $this->doesConditionMatch($rule, $condition, $object); if (!$all && $match) { $reason = pht('Any condition matched.'); $result = true; break; } if ($all && !$match) { $reason = pht('Not all conditions matched.'); $result = false; break; } } if ($result === null) { if ($all) { $reason = pht('All conditions matched.'); $result = true; } else { $reason = pht('No conditions matched.'); $result = false; } } } + // If this rule matched, and is set to run "if it did not match the last + // time", and we matched the last time, we're going to return a match in + // the transcript but set a flag so we don't actually apply any effects. + + // We need the rule to match so that storage gets updated properly. If we + // just pretend the rule didn't match it won't cause any effects (which + // is correct), but it also won't set the "it matched" flag in storage, + // so the next run after this one would incorrectly trigger again. + + $is_dry_run = $this->getDryRun(); + if ($result && !$is_dry_run) { + $is_on_change = $rule->isRepeatOnChange(); + if ($is_on_change) { + $did_apply = $rule->getRuleApplied($object->getPHID()); + if ($did_apply) { + $reason = pht( + 'This rule matched, but did not take any actions because it '. + 'is configured to act only if it did not match the last time.'); + + $this->skipEffects[$rule->getID()] = true; + } + } + } + $this->newRuleTranscript($rule) ->setResult($result) ->setReason($reason); return $result; } protected function doesConditionMatch( HeraldRule $rule, HeraldCondition $condition, HeraldAdapter $object) { $object_value = $this->getConditionObjectValue($condition, $object); $transcript = $this->newConditionTranscript($rule, $condition); try { $result = $object->doesConditionMatch( $this, $rule, $condition, $object_value); } catch (HeraldInvalidConditionException $ex) { $result = false; $transcript->setNote($ex->getMessage()); } $transcript->setResult($result); return $result; } protected function getConditionObjectValue( HeraldCondition $condition, HeraldAdapter $object) { $field = $condition->getFieldName(); return $this->getObjectFieldValue($field); } public function getObjectFieldValue($field) { if (!array_key_exists($field, $this->fieldCache)) { $this->fieldCache[$field] = $this->object->getHeraldField($field); } return $this->fieldCache[$field]; } protected function getRuleEffects( HeraldRule $rule, HeraldAdapter $object) { + $rule_id = $rule->getID(); + if (isset($this->skipEffects[$rule_id])) { + return array(); + } + $effects = array(); foreach ($rule->getActions() as $action) { $effect = id(new HeraldEffect()) ->setObjectPHID($object->getPHID()) ->setAction($action->getAction()) ->setTarget($action->getTarget()) ->setRule($rule); $name = $rule->getName(); $id = $rule->getID(); $effect->setReason( pht( 'Conditions were met for %s', "H{$id} {$name}")); $effects[] = $effect; } return $effects; } private function canAuthorViewObject( HeraldRule $rule, HeraldAdapter $adapter) { // Authorship is irrelevant for global rules and object rules. if ($rule->isGlobalRule() || $rule->isObjectRule()) { return true; } // The author must be able to create rules for the adapter's content type. // In particular, this means that the application must be installed and // accessible to the user. For example, if a user writes a Differential // rule and then loses access to Differential, this disables the rule. $enabled = HeraldAdapter::getEnabledAdapterMap($rule->getAuthor()); if (empty($enabled[$adapter->getAdapterContentType()])) { return false; } // Finally, the author must be able to see the object itself. You can't // write a personal rule that CC's you on revisions you wouldn't otherwise // be able to see, for example. $object = $adapter->getObject(); return PhabricatorPolicyFilter::hasCapability( $rule->getAuthor(), $object, PhabricatorPolicyCapability::CAN_VIEW); } private function canRuleApplyToObject( HeraldRule $rule, HeraldAdapter $adapter) { // Rules which are not object rules can apply to anything. if (!$rule->isObjectRule()) { return true; } $trigger_phid = $rule->getTriggerObjectPHID(); $object_phids = $adapter->getTriggerObjectPHIDs(); if ($object_phids) { if (in_array($trigger_phid, $object_phids)) { return true; } } return false; } private function newRuleTranscript(HeraldRule $rule) { $xscript = id(new HeraldRuleTranscript()) ->setRuleID($rule->getID()) ->setRuleName($rule->getName()) ->setRuleOwner($rule->getAuthorPHID()); $this->transcript->addRuleTranscript($xscript); return $xscript; } private function newConditionTranscript( HeraldRule $rule, HeraldCondition $condition) { $xscript = id(new HeraldConditionTranscript()) ->setRuleID($rule->getID()) ->setConditionID($condition->getID()) ->setFieldName($condition->getFieldName()) ->setCondition($condition->getFieldCondition()) ->setTestValue($condition->getValue()); $this->transcript->addConditionTranscript($xscript); return $xscript; } private function newApplyTranscript( HeraldAdapter $adapter, HeraldRule $rule, HeraldActionRecord $action) { $effect = id(new HeraldEffect()) ->setObjectPHID($adapter->getPHID()) ->setAction($action->getAction()) ->setTarget($action->getTarget()) ->setRule($rule); $xscript = new HeraldApplyTranscript($effect, false); $this->transcript->addApplyTranscript($xscript); return $xscript; } private function isForbidden( HeraldRule $rule, HeraldAdapter $adapter) { $forbidden = $adapter->getForbiddenActions(); if (!$forbidden) { return false; } $forbidden = array_fuse($forbidden); $is_forbidden = false; foreach ($rule->getConditions() as $condition) { $field_key = $condition->getFieldName(); if (!isset($this->forbiddenFields[$field_key])) { $reason = null; try { $states = $adapter->getRequiredFieldStates($field_key); } catch (Exception $ex) { $states = array(); } foreach ($states as $state) { if (!isset($forbidden[$state])) { continue; } $reason = $adapter->getForbiddenReason($state); break; } $this->forbiddenFields[$field_key] = $reason; } $forbidden_reason = $this->forbiddenFields[$field_key]; if ($forbidden_reason !== null) { $this->newConditionTranscript($rule, $condition) ->setResult(HeraldConditionTranscript::RESULT_FORBIDDEN) ->setNote($forbidden_reason); $is_forbidden = true; } } foreach ($rule->getActions() as $action_record) { $action_key = $action_record->getAction(); if (!isset($this->forbiddenActions[$action_key])) { $reason = null; try { $states = $adapter->getRequiredActionStates($action_key); } catch (Exception $ex) { $states = array(); } foreach ($states as $state) { if (!isset($forbidden[$state])) { continue; } $reason = $adapter->getForbiddenReason($state); break; } $this->forbiddenActions[$action_key] = $reason; } $forbidden_reason = $this->forbiddenActions[$action_key]; if ($forbidden_reason !== null) { $this->newApplyTranscript($adapter, $rule, $action_record) ->setAppliedReason( array( array( 'type' => HeraldAction::DO_STANDARD_FORBIDDEN, 'data' => $forbidden_reason, ), )); $is_forbidden = true; } } return $is_forbidden; } } diff --git a/src/applications/herald/storage/HeraldRule.php b/src/applications/herald/storage/HeraldRule.php index 8fd1127eb7..44ef68ac03 100644 --- a/src/applications/herald/storage/HeraldRule.php +++ b/src/applications/herald/storage/HeraldRule.php @@ -1,392 +1,400 @@ true, self::CONFIG_COLUMN_SCHEMA => array( 'name' => 'sort255', 'contentType' => 'text255', 'mustMatchAll' => 'bool', 'configVersion' => 'uint32', 'repetitionPolicy' => 'text32', 'ruleType' => 'text32', 'isDisabled' => 'uint32', 'triggerObjectPHID' => 'phid?', ), self::CONFIG_KEY_SCHEMA => array( 'key_name' => array( 'columns' => array('name(128)'), ), 'key_author' => array( 'columns' => array('authorPHID'), ), 'key_ruletype' => array( 'columns' => array('ruleType'), ), 'key_trigger' => array( 'columns' => array('triggerObjectPHID'), ), ), ) + parent::getConfiguration(); } public function generatePHID() { return PhabricatorPHID::generateNewPHID(HeraldRulePHIDType::TYPECONST); } public function getRuleApplied($phid) { return $this->assertAttachedKey($this->ruleApplied, $phid); } public function setRuleApplied($phid, $applied) { if ($this->ruleApplied === self::ATTACHABLE) { $this->ruleApplied = array(); } $this->ruleApplied[$phid] = $applied; return $this; } public function loadConditions() { if (!$this->getID()) { return array(); } return id(new HeraldCondition())->loadAllWhere( 'ruleID = %d', $this->getID()); } public function attachConditions(array $conditions) { assert_instances_of($conditions, 'HeraldCondition'); $this->conditions = $conditions; return $this; } public function getConditions() { // TODO: validate conditions have been attached. return $this->conditions; } public function loadActions() { if (!$this->getID()) { return array(); } return id(new HeraldActionRecord())->loadAllWhere( 'ruleID = %d', $this->getID()); } public function attachActions(array $actions) { // TODO: validate actions have been attached. assert_instances_of($actions, 'HeraldActionRecord'); $this->actions = $actions; return $this; } public function getActions() { return $this->actions; } public function saveConditions(array $conditions) { assert_instances_of($conditions, 'HeraldCondition'); return $this->saveChildren( id(new HeraldCondition())->getTableName(), $conditions); } public function saveActions(array $actions) { assert_instances_of($actions, 'HeraldActionRecord'); return $this->saveChildren( id(new HeraldActionRecord())->getTableName(), $actions); } protected function saveChildren($table_name, array $children) { assert_instances_of($children, 'HeraldDAO'); if (!$this->getID()) { throw new PhutilInvalidStateException('save'); } foreach ($children as $child) { $child->setRuleID($this->getID()); } $this->openTransaction(); queryfx( $this->establishConnection('w'), 'DELETE FROM %T WHERE ruleID = %d', $table_name, $this->getID()); foreach ($children as $child) { $child->save(); } $this->saveTransaction(); } public function delete() { $this->openTransaction(); queryfx( $this->establishConnection('w'), 'DELETE FROM %T WHERE ruleID = %d', id(new HeraldCondition())->getTableName(), $this->getID()); queryfx( $this->establishConnection('w'), 'DELETE FROM %T WHERE ruleID = %d', id(new HeraldActionRecord())->getTableName(), $this->getID()); $result = parent::delete(); $this->saveTransaction(); return $result; } public function hasValidAuthor() { return $this->assertAttached($this->validAuthor); } public function attachValidAuthor($valid) { $this->validAuthor = $valid; return $this; } public function getAuthor() { return $this->assertAttached($this->author); } public function attachAuthor(PhabricatorUser $user) { $this->author = $user; return $this; } public function isGlobalRule() { return ($this->getRuleType() === HeraldRuleTypeConfig::RULE_TYPE_GLOBAL); } public function isPersonalRule() { return ($this->getRuleType() === HeraldRuleTypeConfig::RULE_TYPE_PERSONAL); } public function isObjectRule() { return ($this->getRuleType() == HeraldRuleTypeConfig::RULE_TYPE_OBJECT); } public function attachTriggerObject($trigger_object) { $this->triggerObject = $trigger_object; return $this; } public function getTriggerObject() { return $this->assertAttached($this->triggerObject); } /** * Get a sortable key for rule execution order. * * Rules execute in a well-defined order: personal rules first, then object * rules, then global rules. Within each rule type, rules execute from lowest * ID to highest ID. * * This ordering allows more powerful rules (like global rules) to override * weaker rules (like personal rules) when multiple rules exist which try to * affect the same field. Executing from low IDs to high IDs makes * interactions easier to understand when adding new rules, because the newest * rules always happen last. * * @return string A sortable key for this rule. */ public function getRuleExecutionOrderSortKey() { $rule_type = $this->getRuleType(); switch ($rule_type) { case HeraldRuleTypeConfig::RULE_TYPE_PERSONAL: $type_order = 1; break; case HeraldRuleTypeConfig::RULE_TYPE_OBJECT: $type_order = 2; break; case HeraldRuleTypeConfig::RULE_TYPE_GLOBAL: $type_order = 3; break; default: throw new Exception(pht('Unknown rule type "%s"!', $rule_type)); } return sprintf('~%d%010d', $type_order, $this->getID()); } public function getMonogram() { return 'H'.$this->getID(); } /* -( Repetition Policies )------------------------------------------------ */ public function getRepetitionPolicyStringConstant() { return $this->getRepetitionPolicy(); } public function setRepetitionPolicyStringConstant($value) { $map = self::getRepetitionPolicyMap(); if (!isset($map[$value])) { throw new Exception( pht( 'Rule repetition string constant "%s" is unknown.', $value)); } return $this->setRepetitionPolicy($value); } public function isRepeatEvery() { return ($this->getRepetitionPolicyStringConstant() === self::REPEAT_EVERY); } public function isRepeatFirst() { return ($this->getRepetitionPolicyStringConstant() === self::REPEAT_FIRST); } + public function isRepeatOnChange() { + return ($this->getRepetitionPolicyStringConstant() === self::REPEAT_CHANGE); + } + public static function getRepetitionPolicySelectOptionMap() { $map = self::getRepetitionPolicyMap(); return ipull($map, 'select'); } private static function getRepetitionPolicyMap() { return array( self::REPEAT_EVERY => array( - 'select' => pht('every time'), + 'select' => pht('every time this rule matches:'), ), self::REPEAT_FIRST => array( - 'select' => pht('only the first time'), + 'select' => pht('only the first time this rule matches:'), + ), + self::REPEAT_CHANGE => array( + 'select' => pht('if this rule did not match the last time:'), ), ); } /* -( PhabricatorApplicationTransactionInterface )------------------------- */ public function getApplicationTransactionEditor() { return new HeraldRuleEditor(); } public function getApplicationTransactionObject() { return $this; } public function getApplicationTransactionTemplate() { return new HeraldRuleTransaction(); } public function willRenderTimeline( PhabricatorApplicationTransactionView $timeline, AphrontRequest $request) { return $timeline; } /* -( PhabricatorPolicyInterface )----------------------------------------- */ public function getCapabilities() { return array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_EDIT, ); } public function getPolicy($capability) { if ($capability == PhabricatorPolicyCapability::CAN_VIEW) { return PhabricatorPolicies::getMostOpenPolicy(); } if ($this->isGlobalRule()) { $app = 'PhabricatorHeraldApplication'; $herald = PhabricatorApplication::getByClass($app); $global = HeraldManageGlobalRulesCapability::CAPABILITY; return $herald->getPolicy($global); } else if ($this->isObjectRule()) { return $this->getTriggerObject()->getPolicy($capability); } else { return $this->getAuthorPHID(); } } public function hasAutomaticCapability($capability, PhabricatorUser $viewer) { return false; } public function describeAutomaticCapability($capability) { if ($capability == PhabricatorPolicyCapability::CAN_VIEW) { return null; } if ($this->isGlobalRule()) { return pht( 'Global Herald rules can be edited by users with the "Can Manage '. 'Global Rules" Herald application permission.'); } else if ($this->isObjectRule()) { return pht('Object rules inherit the edit policies of their objects.'); } else { return pht('A personal rule can only be edited by its owner.'); } } /* -( PhabricatorSubscribableInterface )----------------------------------- */ public function isAutomaticallySubscribed($phid) { return $this->isPersonalRule() && $phid == $this->getAuthorPHID(); } /* -( PhabricatorDestructibleInterface )----------------------------------- */ public function destroyObjectPermanently( PhabricatorDestructionEngine $engine) { $this->openTransaction(); $this->delete(); $this->saveTransaction(); } }