diff --git a/resources/sql/patches/101.heraldruleapplied.sql b/resources/sql/patches/101.heraldruleapplied.sql new file mode 100644 index 0000000000..30c7a1a8ad --- /dev/null +++ b/resources/sql/patches/101.heraldruleapplied.sql @@ -0,0 +1,2 @@ +ALTER TABLE phabricator_herald.herald_ruleapplied + ADD KEY (phid); diff --git a/src/applications/herald/controller/test/HeraldTestConsoleController.php b/src/applications/herald/controller/test/HeraldTestConsoleController.php index a8eb194e79..da94aa46d9 100644 --- a/src/applications/herald/controller/test/HeraldTestConsoleController.php +++ b/src/applications/herald/controller/test/HeraldTestConsoleController.php @@ -1,149 +1,150 @@ getRequest(); $user = $request->getUser(); $request = $this->getRequest(); $object_name = trim($request->getStr('object_name')); $e_name = true; $errors = array(); if ($request->isFormPost()) { if (!$object_name) { $e_name = 'Required'; $errors[] = 'An object name is required.'; } if (!$errors) { $matches = null; $object = null; if (preg_match('/^D(\d+)$/', $object_name, $matches)) { $object = id(new DifferentialRevision())->load($matches[1]); if (!$object) { $e_name = 'Invalid'; $errors[] = 'No Differential Revision with that ID exists.'; } } else if (preg_match('/^r([A-Z]+)(\w+)$/', $object_name, $matches)) { $repo = id(new PhabricatorRepository())->loadOneWhere( 'callsign = %s', $matches[1]); if (!$repo) { $e_name = 'Invalid'; $errors[] = 'There is no repository with the callsign '. $matches[1].'.'; } $commit = id(new PhabricatorRepositoryCommit())->loadOneWhere( 'repositoryID = %d AND commitIdentifier = %s', $repo->getID(), $matches[2]); if (!$commit) { $e_name = 'Invalid'; $errors[] = 'There is no commit with that identifier.'; } $object = $commit; } else { $e_name = 'Invalid'; $errors[] = 'This object name is not recognized.'; } if (!$errors) { if ($object instanceof DifferentialRevision) { $adapter = new HeraldDifferentialRevisionAdapter( $object, $object->loadActiveDiff()); } else if ($object instanceof PhabricatorRepositoryCommit) { $data = id(new PhabricatorRepositoryCommitData())->loadOneWhere( 'commitID = %d', $object->getID()); $adapter = new HeraldCommitAdapter( $repo, $object, $data); } else { throw new Exception("Can not build adapter for object!"); } $rules = HeraldRule::loadAllByContentTypeWithFullData( - $adapter->getHeraldTypeName()); + $adapter->getHeraldTypeName(), + $object->getPHID()); $engine = new HeraldEngine(); $effects = $engine->applyRules($rules, $adapter); $dry_run = new HeraldDryRunAdapter(); $engine->applyEffects($effects, $dry_run); $xscript = $engine->getTranscript(); return id(new AphrontRedirectResponse()) ->setURI('/herald/transcript/'.$xscript->getID().'/'); } } } if ($errors) { $error_view = new AphrontErrorView(); $error_view->setTitle('Form Errors'); $error_view->setErrors($errors); } else { $error_view = null; } $form = id(new AphrontFormView()) ->setUser($user) ->appendChild( '
Enter an object to test rules '. 'for, like a Diffusion commit (e.g., rX123) or a '. 'Differential revision (e.g., D123). You will be shown the '. 'results of a dry run on the object.
') ->appendChild( id(new AphrontFormTextControl()) ->setLabel('Object Name') ->setName('object_name') ->setError($e_name) ->setValue($object_name)) ->appendChild( id(new AphrontFormSubmitControl()) ->setValue('Test Rules')); $panel = new AphrontPanelView(); $panel->setHeader('Test Herald Rules'); $panel->setWidth(AphrontPanelView::WIDTH_FULL); $panel->appendChild($form); return $this->buildStandardPageResponse( array( $error_view, $panel, ), array( 'title' => 'Test Console', 'tab' => 'test', )); } } diff --git a/src/applications/herald/engine/engine/HeraldEngine.php b/src/applications/herald/engine/engine/HeraldEngine.php index 6ef84673a7..9426d8afd1 100644 --- a/src/applications/herald/engine/engine/HeraldEngine.php +++ b/src/applications/herald/engine/engine/HeraldEngine.php @@ -1,482 +1,484 @@ getHeraldTypeName(); - $rules = HeraldRule::loadAllByContentTypeWithFullData($content_type); + $rules = HeraldRule::loadAllByContentTypeWithFullData( + $content_type, + $object->getPHID()); $engine = new HeraldEngine(); $effects = $engine->applyRules($rules, $object); $engine->applyEffects($effects, $object); return $engine->getTranscript(); } public function applyRules(array $rules, HeraldObjectAdapter $object) { $t_start = microtime(true); $rules = mpull($rules, null, 'getID'); $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 $id => $rule) { $this->stack = array(); try { if (($rule->getRepetitionPolicy() == HeraldRepetitionPolicyConfig::FIRST) && $rule->getRuleApplied($object->getPHID())) { // This rule is only supposed to be applied a single time, and it's // aleady been applied, so this is an automatic failure. $xscript = id(new HeraldRuleTranscript()) ->setRuleID($id) ->setResult(false) ->setRuleName($rule->getName()) ->setRuleOwner($rule->getAuthorPHID()) ->setReason( "This rule is only supposed to be repeated a single time, ". "and it has already been applied." ); $this->transcript->addRuleTranscript($xscript); $rule_matches = false; } else { $rule_matches = $this->doesRuleMatch($rule, $object); } } catch (HeraldRecursiveConditionsException $ex) { $names = array(); foreach ($this->stack as $rule_id => $ignored) { $names[] = '"'.$rules[$rule_id]->getName().'"'; } $names = implode(', ', $names); foreach ($this->stack as $rule_id => $ignored) { $xscript = new HeraldRuleTranscript(); $xscript->setRuleID($rule_id); $xscript->setResult(false); $xscript->setReason( "Rules {$names} are recursively dependent upon one another! ". "Don't do this! You have formed an unresolvable cycle in the ". "dependency graph!"); $xscript->setRuleName($rules[$rule_id]->getName()); $xscript->setRuleOwner($rules[$rule_id]->getAuthorPHID()); $this->transcript->addRuleTranscript($xscript); } $rule_matches = false; } $this->results[$id] = $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->getHeraldTypeName()); $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, HeraldObjectAdapter $object) { $this->transcript->setDryRun($object instanceof HeraldDryRunAdapter); $xscripts = $object->applyHeraldEffects($effects); foreach ($xscripts as $apply_xscript) { if (!($apply_xscript instanceof HeraldApplyTranscript)) { throw new Exception( "Heraldable must return HeraldApplyTranscripts from ". "applyHeraldEffect()."); } $this->transcript->addApplyTranscript($apply_xscript); } if (!$this->transcript->getDryRun()) { // Mark all the rules that have had their effects applied as having been // executed for the current object. $rule_ids = mpull($xscripts, 'getRuleID'); 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; } HeraldRule::saveRuleApplied($rule_id, $object->getPHID()); } } } public function getTranscript() { $this->transcript->save(); return $this->transcript; } protected function doesRuleMatch( HeraldRule $rule, HeraldObjectAdapter $object) { $id = $rule->getID(); if (isset($this->results[$id])) { // If we've already evaluated this rule because another rule depends // on it, we don't need to reevaluate it. return $this->results[$id]; } if (isset($this->stack[$id])) { // 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_id => $ignored) { $this->results[$rule_id] = false; } throw new HeraldRecursiveConditionsException(); } $this->stack[$id] = true; $all = $rule->getMustMatchAll(); $conditions = $rule->getConditions(); $result = null; $local_version = id(new HeraldRule())->getConfigVersion(); if ($rule->getConfigVersion() > $local_version) { $reason = "Rule could not be processed, it was created with a newer ". "version of Herald."; $result = false; } else if (!$conditions) { $reason = "Rule failed automatically because it has no conditions."; $result = false; /* TOOD: Restore this in some form? } else if (!is_fb_employee($rule->getAuthorPHID())) { $reason = "Rule failed automatically because its owner is not an ". "active employee."; $result = false; */ } else { foreach ($conditions as $condition) { $match = $this->doesConditionMatch($rule, $condition, $object); if (!$all && $match) { $reason = "Any condition matched."; $result = true; break; } if ($all && !$match) { $reason = "Not all conditions matched."; $result = false; break; } } if ($result === null) { if ($all) { $reason = "All conditions matched."; $result = true; } else { $reason = "No conditions matched."; $result = false; } } } $rule_transcript = new HeraldRuleTranscript(); $rule_transcript->setRuleID($rule->getID()); $rule_transcript->setResult($result); $rule_transcript->setReason($reason); $rule_transcript->setRuleName($rule->getName()); $rule_transcript->setRuleOwner($rule->getAuthorPHID()); $this->transcript->addRuleTranscript($rule_transcript); return $result; } protected function doesConditionMatch( HeraldRule $rule, HeraldCondition $condition, HeraldObjectAdapter $object) { $object_value = $this->getConditionObjectValue($condition, $object); $test_value = $condition->getValue(); $cond = $condition->getFieldCondition(); $transcript = new HeraldConditionTranscript(); $transcript->setRuleID($rule->getID()); $transcript->setConditionID($condition->getID()); $transcript->setFieldName($condition->getFieldName()); $transcript->setCondition($cond); $transcript->setTestValue($test_value); $result = null; switch ($cond) { case HeraldConditionConfig::CONDITION_CONTAINS: // "Contains" can take an array of strings, as in "Any changed // filename" for diffs. foreach ((array)$object_value as $value) { $result = (stripos($value, $test_value) !== false); if ($result) { break; } } break; case HeraldConditionConfig::CONDITION_NOT_CONTAINS: $result = (stripos($object_value, $test_value) === false); break; case HeraldConditionConfig::CONDITION_IS: $result = ($object_value == $test_value); break; case HeraldConditionConfig::CONDITION_IS_NOT: $result = ($object_value != $test_value); break; case HeraldConditionConfig::CONDITION_IS_ME: $result = ($object_value == $rule->getAuthorPHID()); break; case HeraldConditionConfig::CONDITION_IS_NOT_ME: $result = ($object_value != $rule->getAuthorPHID()); break; case HeraldConditionConfig::CONDITION_IS_ANY: $test_value = array_flip($test_value); $result = isset($test_value[$object_value]); break; case HeraldConditionConfig::CONDITION_IS_NOT_ANY: $test_value = array_flip($test_value); $result = !isset($test_value[$object_value]); break; case HeraldConditionConfig::CONDITION_INCLUDE_ALL: if (!is_array($object_value)) { $transcript->setNote('Object produced bad value!'); $result = false; } else { $have = array_select_keys(array_flip($object_value), $test_value); $result = (count($have) == count($test_value)); } break; case HeraldConditionConfig::CONDITION_INCLUDE_ANY: $result = (bool)array_select_keys(array_flip($object_value), $test_value); break; case HeraldConditionConfig::CONDITION_INCLUDE_NONE: $result = !array_select_keys(array_flip($object_value), $test_value); break; case HeraldConditionConfig::CONDITION_EXISTS: $result = (bool)$object_value; break; case HeraldConditionConfig::CONDITION_NOT_EXISTS: $result = !$object_value; break; case HeraldConditionConfig::CONDITION_REGEXP: foreach ((array)$object_value as $value) { $result = @preg_match($test_value, $value); if ($result === false) { $transcript->setNote( "Regular expression is not valid!"); break; } if ($result) { break; } } $result = (bool)$result; break; case HeraldConditionConfig::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 = json_decode($test_value, true); if (!is_array($regexp_pair)) { $result = false; $transcript->setNote("Regular expression pair is not valid JSON!"); break; } if (count($regexp_pair) != 2) { $result = false; $transcript->setNote("Regular expression pair is not a pair!"); break; } $key_regexp = array_shift($regexp_pair); $value_regexp = array_shift($regexp_pair); foreach ((array)$object_value as $key => $value) { $key_matches = @preg_match($key_regexp, $key); if ($key_matches === false) { $result = false; $transcript->setNote("First regular expression is invalid!"); break 2; } if ($key_matches) { $value_matches = @preg_match($value_regexp, $value); if ($value_matches === false) { $result = false; $transcript->setNote("Second regular expression is invalid!"); break 2; } if ($value_matches) { $result = true; break 2; } } } $result = false; break; case HeraldConditionConfig::CONDITION_RULE: case HeraldConditionConfig::CONDITION_NOT_RULE: $rule = idx($this->rules, $test_value); if (!$rule) { $transcript->setNote( "Condition references a rule which does not exist!"); $result = false; } else { $is_not = ($cond == HeraldConditionConfig::CONDITION_NOT_RULE); $result = $this->doesRuleMatch($rule, $object); if ($is_not) { $result = !$result; } } break; default: throw new HeraldInvalidConditionException( "Unknown condition '{$cond}'."); } $transcript->setResult($result); $this->transcript->addConditionTranscript($transcript); return $result; } protected function getConditionObjectValue( HeraldCondition $condition, HeraldObjectAdapter $object) { $field = $condition->getFieldName(); return $this->getObjectFieldValue($field); } public function getObjectFieldValue($field) { if (isset($this->fieldCache[$field])) { return $this->fieldCache[$field]; } $result = null; switch ($field) { case HeraldFieldConfig::FIELD_RULE: $result = null; break; case HeraldFieldConfig::FIELD_TITLE: case HeraldFieldConfig::FIELD_BODY: case HeraldFieldConfig::FIELD_DIFF_FILE: case HeraldFieldConfig::FIELD_DIFF_CONTENT: // TODO: Type should be string. $result = $this->object->getHeraldField($field); break; case HeraldFieldConfig::FIELD_AUTHOR: case HeraldFieldConfig::FIELD_REPOSITORY: case HeraldFieldConfig::FIELD_MERGE_REQUESTER: // TODO: Type should be PHID. $result = $this->object->getHeraldField($field); break; case HeraldFieldConfig::FIELD_TAGS: case HeraldFieldConfig::FIELD_REVIEWER: case HeraldFieldConfig::FIELD_REVIEWERS: case HeraldFieldConfig::FIELD_CC: case HeraldFieldConfig::FIELD_DIFFERENTIAL_REVIEWERS: case HeraldFieldConfig::FIELD_DIFFERENTIAL_CCS: // TODO: Type should be list. $result = $this->object->getHeraldField($field); break; case HeraldFieldConfig::FIELD_AFFECTED_PACKAGE: case HeraldFieldConfig::FIELD_AFFECTED_PACKAGE_OWNER: case HeraldFieldConfig::FIELD_NEED_AUDIT_FOR_PACKAGE: $result = $this->object->getHeraldField($field); if (!is_array($result)) { throw new HeraldInvalidFieldException( "Value of field type {$field} is not an array!"); } break; case HeraldFieldConfig::FIELD_DIFFERENTIAL_REVISION: // TODO: Type should be boolean I guess. $result = $this->object->getHeraldField($field); break; default: throw new HeraldInvalidConditionException( "Unknown field type '{$field}'!"); } $this->fieldCache[$field] = $result; return $result; } protected function getRuleEffects( HeraldRule $rule, HeraldObjectAdapter $object) { $effects = array(); foreach ($rule->getActions() as $action) { $effect = new HeraldEffect(); $effect->setObjectPHID($object->getPHID()); $effect->setAction($action->getAction()); $effect->setTarget($action->getTarget()); $effect->setRuleID($rule->getID()); $name = $rule->getName(); $id = $rule->getID(); $effect->setReason( 'Conditions were met for Herald rule "'.$name.'" (#'.$id.').'); $effects[] = $effect; } return $effects; } } diff --git a/src/applications/herald/storage/rule/HeraldRule.php b/src/applications/herald/storage/rule/HeraldRule.php index bc1702a15f..4938fc318c 100644 --- a/src/applications/herald/storage/rule/HeraldRule.php +++ b/src/applications/herald/storage/rule/HeraldRule.php @@ -1,187 +1,189 @@ loadAllWhere( 'contentType = %s', $content_type); if (!$rules) { return array(); } $rule_ids = mpull($rules, 'getID'); $conditions = id(new HeraldCondition())->loadAllWhere( 'ruleID in (%Ld)', $rule_ids); $actions = id(new HeraldAction())->loadAllWhere( 'ruleID in (%Ld)', $rule_ids); $applied = queryfx_all( id(new HeraldRule())->establishConnection('r'), - 'SELECT * FROM %T WHERE ruleID in (%Ld)', - self::TABLE_RULE_APPLIED, $rule_ids - ); + 'SELECT * FROM %T WHERE phid = %s', + self::TABLE_RULE_APPLIED, + $object_phid); + $applied = ipull($applied, null, 'ruleID'); $conditions = mgroup($conditions, 'getRuleID'); $actions = mgroup($actions, 'getRuleID'); $applied = igroup($applied, 'ruleID'); - foreach ($rules as $rule) { - $rule->attachAllRuleApplied(idx($applied, $rule->getID(), array())); + $rule->setRuleApplied($object_phid, isset($applied[$rule->getID()])); + $rule->attachConditions(idx($conditions, $rule->getID(), array())); $rule->attachActions(idx($actions, $rule->getID(), array())); } return $rules; } public function getRuleApplied($phid) { - // defaults to false because (ruleID, phid) pairs not in the db imply - // a rule that's not been applied before - return idx($this->ruleApplied, $phid, false); + if (idx($this->ruleApplied, $phid) === null) { + throw new Exception("Call setRuleApplied() before getRuleApplied()!"); + } + return $this->ruleApplied[$phid]; } - public function setRuleApplied($phid) { - $this->ruleApplied[$phid] = true; + public function setRuleApplied($phid, $applied) { + $this->ruleApplied[$phid] = $applied; + return $this; } - public function attachAllRuleApplied(array $applied) { - // turn array of array(ruleID, phid) into array of ruleID => true - $this->ruleApplied = array_fill_keys(ipull($applied, 'phid'), true); - } - public static function saveRuleApplied($rule_id, $phid) { queryfx( id(new HeraldRule())->establishConnection('w'), 'INSERT IGNORE INTO %T (phid, ruleID) VALUES (%s, %d)', - self::TABLE_RULE_APPLIED, $phid, $rule_id - ); + self::TABLE_RULE_APPLIED, + $phid, + $rule_id); } public function loadConditions() { if (!$this->getID()) { return array(); } return id(new HeraldCondition())->loadAllWhere( 'ruleID = %d', $this->getID()); } public function attachConditions(array $conditions) { $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 HeraldAction())->loadAllWhere( 'ruleID = %d', $this->getID()); } public function attachActions(array $actions) { // TODO: validate actions have been attached. $this->actions = $actions; return $this; } public function getActions() { return $this->actions; } public function saveConditions(array $conditions) { return $this->saveChildren( id(new HeraldCondition())->getTableName(), $conditions); } public function saveActions(array $actions) { return $this->saveChildren( id(new HeraldAction())->getTableName(), $actions); } protected function saveChildren($table_name, array $children) { if (!$this->getID()) { throw new Exception("Save rule before saving children."); } foreach ($children as $child) { $child->setRuleID($this->getID()); } // TODO: // $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() { // TODO: // $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 HeraldAction())->getTableName(), $this->getID()); parent::delete(); // $this->saveTransaction(); } } diff --git a/src/applications/repository/worker/herald/PhabricatorRepositoryCommitHeraldWorker.php b/src/applications/repository/worker/herald/PhabricatorRepositoryCommitHeraldWorker.php index 3ba9992f49..acf34dfa68 100644 --- a/src/applications/repository/worker/herald/PhabricatorRepositoryCommitHeraldWorker.php +++ b/src/applications/repository/worker/herald/PhabricatorRepositoryCommitHeraldWorker.php @@ -1,139 +1,140 @@ getDetail('herald-disabled')) { return; } $data = id(new PhabricatorRepositoryCommitData())->loadOneWhere( 'commitID = %d', $commit->getID()); $rules = HeraldRule::loadAllByContentTypeWithFullData( - HeraldContentTypeConfig::CONTENT_TYPE_COMMIT); + HeraldContentTypeConfig::CONTENT_TYPE_COMMIT, + $commit->getPHID()); $adapter = new HeraldCommitAdapter( $repository, $commit, $data); $engine = new HeraldEngine(); $effects = $engine->applyRules($rules, $adapter); $engine->applyEffects($effects, $adapter); $email_phids = $adapter->getEmailPHIDs(); if (!$email_phids) { return; } $xscript = $engine->getTranscript(); $commit_name = $adapter->getHeraldName(); $revision = $adapter->loadDifferentialRevision(); $name = null; if ($revision) { $name = ' '.$revision->getTitle(); } $author_phid = $data->getCommitDetail('authorPHID'); $reviewer_phid = $data->getCommitDetail('reviewerPHID'); $phids = array_filter(array($author_phid, $reviewer_phid)); $handles = array(); if ($phids) { $handles = id(new PhabricatorObjectHandleData($phids))->loadHandles(); } if ($author_phid) { $author_name = $handles[$author_phid]->getName(); } else { $author_name = $data->getAuthorName(); } if ($reviewer_phid) { $reviewer_name = $handles[$reviewer_phid]->getName(); } else { $reviewer_name = null; } $who = implode(', ', array_filter(array($author_name, $reviewer_name))); $description = $data->getCommitMessage(); $details = PhabricatorEnv::getProductionURI('/'.$commit_name); $differential = $revision ? PhabricatorEnv::getProductionURI('/D'.$revision->getID()) : 'No revision.'; $files = $adapter->loadAffectedPaths(); sort($files); $files = implode("\n ", $files); $xscript_id = $xscript->getID(); $manage_uri = PhabricatorEnv::getProductionURI('/herald/view/commits/'); $why_uri = PhabricatorEnv::getProductionURI( '/herald/transcript/'.$xscript_id.'/'); $body = <<