@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.)
hq.recaptime.dev/wiki/Phorge
phorge
phabricator
1<?php
2
3final class HeraldEngine extends Phobject {
4
5 protected $rules = array();
6 protected $transcript;
7
8 private $fieldCache = array();
9 private $fieldExceptions = array();
10 protected $object;
11 private $dryRun;
12
13 private $forbiddenFields = array();
14 private $forbiddenActions = array();
15 private $skipEffects = array();
16
17 private $profilerStack = array();
18 private $profilerFrames = array();
19
20 private $ruleResults;
21 private $ruleStack;
22
23 public function setDryRun($dry_run) {
24 $this->dryRun = $dry_run;
25 return $this;
26 }
27
28 public function getDryRun() {
29 return $this->dryRun;
30 }
31
32 public function getRule($phid) {
33 return idx($this->rules, $phid);
34 }
35
36 public function loadRulesForAdapter(HeraldAdapter $adapter) {
37 return id(new HeraldRuleQuery())
38 ->setViewer(PhabricatorUser::getOmnipotentUser())
39 ->withDisabled(false)
40 ->withContentTypes(array($adapter->getAdapterContentType()))
41 ->needConditionsAndActions(true)
42 ->needAppliedToPHIDs(array($adapter->getPHID()))
43 ->needValidateAuthors(true)
44 ->execute();
45 }
46
47 public static function loadAndApplyRules(HeraldAdapter $adapter) {
48 $engine = new HeraldEngine();
49
50 $rules = $engine->loadRulesForAdapter($adapter);
51 $effects = $engine->applyRules($rules, $adapter);
52 $engine->applyEffects($effects, $adapter, $rules);
53
54 return $engine->getTranscript();
55 }
56
57/* -( Rule Stack )--------------------------------------------------------- */
58
59 private function resetRuleStack() {
60 $this->ruleStack = array();
61 return $this;
62 }
63
64 private function hasRuleOnStack(HeraldRule $rule) {
65 $phid = $rule->getPHID();
66 return isset($this->ruleStack[$phid]);
67 }
68
69 private function pushRuleStack(HeraldRule $rule) {
70 $phid = $rule->getPHID();
71 $this->ruleStack[$phid] = $rule;
72 return $this;
73 }
74
75 private function getRuleStack() {
76 return array_values($this->ruleStack);
77 }
78
79/* -( Rule Results )------------------------------------------------------- */
80
81 private function resetRuleResults() {
82 $this->ruleResults = array();
83 return $this;
84 }
85
86 private function setRuleResult(
87 HeraldRule $rule,
88 HeraldRuleResult $result) {
89
90 $phid = $rule->getPHID();
91
92 if ($this->hasRuleResult($rule)) {
93 throw new Exception(
94 pht(
95 'Herald rule "%s" already has an evaluation result.',
96 $phid));
97 }
98
99 $this->ruleResults[$phid] = $result;
100
101 $this->newRuleTranscript($rule)
102 ->setRuleResult($result);
103
104 return $this;
105 }
106
107 private function hasRuleResult(HeraldRule $rule) {
108 $phid = $rule->getPHID();
109 return isset($this->ruleResults[$phid]);
110 }
111
112 private function getRuleResult(HeraldRule $rule) {
113 $phid = $rule->getPHID();
114
115 if (!$this->hasRuleResult($rule)) {
116 throw new Exception(
117 pht(
118 'Herald rule "%s" does not have an evaluation result.',
119 $phid));
120 }
121
122 return $this->ruleResults[$phid];
123 }
124
125 /**
126 * @param array<HeraldRule> $rules
127 * @param HeraldAdapter $object
128 * @return array<HeraldEffect>
129 */
130 public function applyRules(array $rules, HeraldAdapter $object) {
131 assert_instances_of($rules, HeraldRule::class);
132 $t_start = microtime(true);
133
134 // Rules execute in a well-defined order: sort them into execution order.
135 $rules = msort($rules, 'getRuleExecutionOrderSortKey');
136 $rules = mpull($rules, null, 'getPHID');
137
138 $this->transcript = new HeraldTranscript();
139 $this->transcript->setObjectPHID((string)$object->getPHID());
140 $this->fieldCache = array();
141 $this->fieldExceptions = array();
142 $this->rules = $rules;
143 $this->object = $object;
144
145 $this->resetRuleResults();
146
147 $effects = array();
148 foreach ($rules as $phid => $rule) {
149 $this->resetRuleStack();
150
151 $caught = null;
152 $result = null;
153 try {
154 $is_first_only = $rule->isRepeatFirst();
155
156 if (!$this->getDryRun() &&
157 $is_first_only &&
158 $rule->getRuleApplied($object->getPHID())) {
159
160 // This is not a dry run, and this rule is only supposed to be
161 // applied a single time, and it has already been applied.
162 // That means automatic failure.
163
164 $result_code = HeraldRuleResult::RESULT_ALREADY_APPLIED;
165 $result = HeraldRuleResult::newFromResultCode($result_code);
166 } else if ($this->isForbidden($rule, $object)) {
167 $result_code = HeraldRuleResult::RESULT_OBJECT_STATE;
168 $result = HeraldRuleResult::newFromResultCode($result_code);
169 } else {
170 $result = $this->getRuleMatchResult($rule, $object);
171 }
172 } catch (HeraldRecursiveConditionsException $ex) {
173 $cycle_phids = array();
174
175 $stack = $this->getRuleStack();
176 foreach ($stack as $stack_rule) {
177 $cycle_phids[] = $stack_rule->getPHID();
178 }
179 // Add the rule which actually cycled to the list to make the
180 // result more clear when we show it to the user.
181 $cycle_phids[] = $phid;
182
183 foreach ($stack as $stack_rule) {
184 if ($this->hasRuleResult($stack_rule)) {
185 continue;
186 }
187
188 $result_code = HeraldRuleResult::RESULT_RECURSION;
189 $result_data = array(
190 'cyclePHIDs' => $cycle_phids,
191 );
192
193 $result = HeraldRuleResult::newFromResultCode($result_code)
194 ->setResultData($result_data);
195 $this->setRuleResult($stack_rule, $result);
196 }
197
198 $result = $this->getRuleResult($rule);
199 } catch (HeraldRuleEvaluationException $ex) {
200 // When we encounter an evaluation exception, the condition which
201 // failed to evaluate is responsible for logging the details of the
202 // error.
203
204 $result_code = HeraldRuleResult::RESULT_EVALUATION_EXCEPTION;
205 $result = HeraldRuleResult::newFromResultCode($result_code);
206 } catch (Exception $ex) {
207 $caught = $ex;
208 } catch (Throwable $ex) {
209 $caught = $ex;
210 }
211
212 if ($caught) {
213 // These exceptions are unexpected, and did not arise during rule
214 // evaluation, so we're responsible for handling the details.
215
216 $result_code = HeraldRuleResult::RESULT_EXCEPTION;
217
218 $result_data = array(
219 'exception.class' => get_class($caught),
220 'exception.message' => $ex->getMessage(),
221 );
222
223 $result = HeraldRuleResult::newFromResultCode($result_code)
224 ->setResultData($result_data);
225 }
226
227 if (!$this->hasRuleResult($rule)) {
228 $this->setRuleResult($rule, $result);
229 }
230 $result = $this->getRuleResult($rule);
231
232 if ($result->getShouldApplyActions()) {
233 foreach ($this->getRuleEffects($rule, $object) as $effect) {
234 $effects[] = $effect;
235 }
236 }
237 }
238
239 $xaction_phids = null;
240 $xactions = $object->getAppliedTransactions();
241 if ($xactions !== null) {
242 $xaction_phids = mpull($xactions, 'getPHID');
243 }
244
245 $object_transcript = id(new HeraldObjectTranscript())
246 ->setPHID($object->getPHID())
247 ->setName($object->getHeraldName())
248 ->setType($object->getAdapterContentType())
249 ->setFields($this->fieldCache)
250 ->setAppliedTransactionPHIDs($xaction_phids)
251 ->setProfile($this->getProfile());
252
253 $this->transcript->setObjectTranscript($object_transcript);
254
255 $t_end = microtime(true);
256
257 $this->transcript->setDuration($t_end - $t_start);
258
259 return $effects;
260 }
261
262 /**
263 * @param array<HeraldEffect> $effects
264 * @param HeraldAdapter $adapter
265 * @param array<HeraldRule> $rules
266 * @return array
267 */
268 public function applyEffects(
269 array $effects,
270 HeraldAdapter $adapter,
271 array $rules) {
272 assert_instances_of($effects, HeraldEffect::class);
273 assert_instances_of($rules, HeraldRule::class);
274
275 $this->transcript->setDryRun((int)$this->getDryRun());
276
277 if ($this->getDryRun()) {
278 $xscripts = array();
279 foreach ($effects as $effect) {
280 $xscripts[] = new HeraldApplyTranscript(
281 $effect,
282 false,
283 pht('This was a dry run, so no actions were actually taken.'));
284 }
285 } else {
286 $xscripts = $adapter->applyHeraldEffects($effects);
287 }
288
289 assert_instances_of($xscripts, HeraldApplyTranscript::class);
290 foreach ($xscripts as $apply_xscript) {
291 $this->transcript->addApplyTranscript($apply_xscript);
292 }
293
294 // For dry runs, don't mark the rule as having applied to the object.
295 if ($this->getDryRun()) {
296 return;
297 }
298
299 // Update the "applied" state table. How this table works depends on the
300 // repetition policy for the rule.
301 //
302 // REPEAT_EVERY: We delete existing rows for the rule, then write nothing.
303 // This policy doesn't use any state.
304 //
305 // REPEAT_FIRST: We keep existing rows, then write additional rows for
306 // rules which fired. This policy accumulates state over the life of the
307 // object.
308 //
309 // REPEAT_CHANGE: We delete existing rows, then write all the rows which
310 // matched. This policy only uses the state from the previous run.
311
312 $rules = mpull($rules, null, 'getID');
313 $rule_ids = mpull($xscripts, 'getRuleID');
314
315 $delete_ids = array();
316 foreach ($rules as $rule_id => $rule) {
317 if ($rule->isRepeatFirst()) {
318 continue;
319 }
320 $delete_ids[] = $rule_id;
321 }
322
323 $applied_ids = array();
324 foreach ($rule_ids as $rule_id) {
325 if (!$rule_id) {
326 // Some apply transcripts are purely informational and not associated
327 // with a rule, e.g. carryover emails from earlier revisions.
328 continue;
329 }
330
331 $rule = idx($rules, $rule_id);
332 if (!$rule) {
333 continue;
334 }
335
336 if ($rule->isRepeatFirst() || $rule->isRepeatOnChange()) {
337 $applied_ids[] = $rule_id;
338 }
339 }
340
341 // Also include "only if this rule did not match the last time" rules
342 // which matched but were skipped in the "applied" list.
343 foreach ($this->skipEffects as $rule_id => $ignored) {
344 $applied_ids[] = $rule_id;
345 }
346
347 if ($delete_ids || $applied_ids) {
348 $conn_w = id(new HeraldRule())->establishConnection('w');
349
350 if ($delete_ids) {
351 queryfx(
352 $conn_w,
353 'DELETE FROM %T WHERE phid = %s AND ruleID IN (%Ld)',
354 HeraldRule::TABLE_RULE_APPLIED,
355 $adapter->getPHID(),
356 $delete_ids);
357 }
358
359 if ($applied_ids) {
360 $sql = array();
361 foreach ($applied_ids as $id) {
362 $sql[] = qsprintf(
363 $conn_w,
364 '(%s, %d)',
365 $adapter->getPHID(),
366 $id);
367 }
368 queryfx(
369 $conn_w,
370 'INSERT IGNORE INTO %T (phid, ruleID) VALUES %LQ',
371 HeraldRule::TABLE_RULE_APPLIED,
372 $sql);
373 }
374 }
375 }
376
377 public function getTranscript() {
378 $this->transcript->save();
379 return $this->transcript;
380 }
381
382 public function doesRuleMatch(
383 HeraldRule $rule,
384 HeraldAdapter $object) {
385 $result = $this->getRuleMatchResult($rule, $object);
386 return $result->getShouldApplyActions();
387 }
388
389 private function getRuleMatchResult(
390 HeraldRule $rule,
391 HeraldAdapter $object) {
392
393 if ($this->hasRuleResult($rule)) {
394 // If we've already evaluated this rule because another rule depends
395 // on it, we don't need to reevaluate it.
396 return $this->getRuleResult($rule);
397 }
398
399 if ($this->hasRuleOnStack($rule)) {
400 // We've recursed, fail all of the rules on the stack. This happens when
401 // there's a dependency cycle with "Rule conditions match for rule ..."
402 // conditions.
403 throw new HeraldRecursiveConditionsException();
404 }
405 $this->pushRuleStack($rule);
406
407 $all = $rule->getMustMatchAll();
408
409 $conditions = $rule->getConditions();
410
411 $result_code = null;
412 $result_data = array();
413
414 $local_version = id(new HeraldRule())->getConfigVersion();
415 if ($rule->getConfigVersion() > $local_version) {
416 $result_code = HeraldRuleResult::RESULT_VERSION;
417 } else if (!$conditions) {
418 $result_code = HeraldRuleResult::RESULT_EMPTY;
419 } else if (!$rule->hasValidAuthor()) {
420 $result_code = HeraldRuleResult::RESULT_OWNER;
421 } else if (!$this->canAuthorViewObject($rule, $object)) {
422 $result_code = HeraldRuleResult::RESULT_VIEW_POLICY;
423 } else if (!$this->canRuleApplyToObject($rule, $object)) {
424 $result_code = HeraldRuleResult::RESULT_OBJECT_RULE;
425 } else {
426 foreach ($conditions as $condition) {
427 $caught = null;
428
429 try {
430 $match = $this->doesConditionMatch(
431 $rule,
432 $condition,
433 $object);
434 } catch (HeraldRuleEvaluationException $ex) {
435 throw $ex;
436 } catch (HeraldRecursiveConditionsException $ex) {
437 throw $ex;
438 } catch (Exception $ex) {
439 $caught = $ex;
440 } catch (Throwable $ex) {
441 $caught = $ex;
442 }
443
444 if ($caught) {
445 throw new HeraldRuleEvaluationException();
446 }
447
448 if (!$all && $match) {
449 $result_code = HeraldRuleResult::RESULT_ANY_MATCHED;
450 break;
451 }
452
453 if ($all && !$match) {
454 $result_code = HeraldRuleResult::RESULT_ANY_FAILED;
455 break;
456 }
457 }
458
459 if ($result_code === null) {
460 if ($all) {
461 $result_code = HeraldRuleResult::RESULT_ALL_MATCHED;
462 } else {
463 $result_code = HeraldRuleResult::RESULT_ALL_FAILED;
464 }
465 }
466 }
467
468 // If this rule matched, and is set to run "if it did not match the last
469 // time", and we matched the last time, we're going to return a special
470 // result code which records a match but doesn't actually apply effects.
471
472 // We need the rule to match so that storage gets updated properly. If we
473 // just pretend the rule didn't match it won't cause any effects (which
474 // is correct), but it also won't set the "it matched" flag in storage,
475 // so the next run after this one would incorrectly trigger again.
476
477 $result = HeraldRuleResult::newFromResultCode($result_code)
478 ->setResultData($result_data);
479
480 $should_apply = $result->getShouldApplyActions();
481
482 $is_dry_run = $this->getDryRun();
483 if ($should_apply && !$is_dry_run) {
484 $is_on_change = $rule->isRepeatOnChange();
485 if ($is_on_change) {
486 $did_apply = $rule->getRuleApplied($object->getPHID());
487 if ($did_apply) {
488 // Replace the result with our modified result.
489 $result_code = HeraldRuleResult::RESULT_LAST_MATCHED;
490 $result = HeraldRuleResult::newFromResultCode($result_code);
491
492 $this->skipEffects[$rule->getID()] = true;
493 }
494 }
495 }
496
497 $this->setRuleResult($rule, $result);
498
499 return $result;
500 }
501
502 private function doesConditionMatch(
503 HeraldRule $rule,
504 HeraldCondition $condition,
505 HeraldAdapter $adapter) {
506
507 $transcript = $this->newConditionTranscript($rule, $condition);
508
509 $caught = null;
510 $result_data = array();
511
512 try {
513 $field_key = $condition->getFieldName();
514
515 $field_value = $this->getProfiledObjectFieldValue(
516 $adapter,
517 $field_key);
518
519 $is_match = $this->getProfiledConditionMatch(
520 $adapter,
521 $rule,
522 $condition,
523 $field_value);
524 if ($is_match) {
525 $result_code = HeraldConditionResult::RESULT_MATCHED;
526 } else {
527 $result_code = HeraldConditionResult::RESULT_FAILED;
528 }
529 } catch (HeraldRecursiveConditionsException $ex) {
530 $result_code = HeraldConditionResult::RESULT_RECURSION;
531 $caught = $ex;
532 } catch (HeraldInvalidConditionException $ex) {
533 $result_code = HeraldConditionResult::RESULT_INVALID;
534 $caught = $ex;
535 } catch (Exception $ex) {
536 $result_code = HeraldConditionResult::RESULT_EXCEPTION;
537 $caught = $ex;
538 } catch (Throwable $ex) {
539 $result_code = HeraldConditionResult::RESULT_EXCEPTION;
540 $caught = $ex;
541 }
542
543 if ($caught) {
544 $result_data = array(
545 'exception.class' => get_class($caught),
546 'exception.message' => $caught->getMessage(),
547 );
548 phlog(pht('An exception occurred executing Herald rule %s: "%s" Review '.
549 'the Herald transcripts and correct or disable the problematic rule.',
550 $rule->getMonogram(),
551 $caught->getMessage()));
552 }
553
554 $result = HeraldConditionResult::newFromResultCode($result_code)
555 ->setResultData($result_data);
556
557 $transcript->setResult($result);
558
559 if ($caught) {
560 throw $caught;
561 }
562
563 return $result->getIsMatch();
564 }
565
566 private function getProfiledConditionMatch(
567 HeraldAdapter $adapter,
568 HeraldRule $rule,
569 HeraldCondition $condition,
570 $field_value) {
571
572 // Here, we're profiling the cost to match the condition value against
573 // whatever test is configured. Normally, this cost should be very
574 // small (<<1ms) since it amounts to a single comparison:
575 //
576 // [ Task author ][ is any of ][ alice ]
577 //
578 // However, it may be expensive in some cases, particularly if you
579 // write a rule with a very creative regular expression that backtracks
580 // explosively.
581 //
582 // At time of writing, the "Another Herald Rule" field is also
583 // evaluated inside the matching function. This may be arbitrarily
584 // expensive (it can prompt us to execute any finite number of other
585 // Herald rules), although we'll push the profiler stack appropriately
586 // so we don't count the evaluation time against this rule in the final
587 // profile.
588
589 $this->pushProfilerRule($rule);
590
591 $caught = null;
592 try {
593 $is_match = $adapter->doesConditionMatch(
594 $this,
595 $rule,
596 $condition,
597 $field_value);
598 } catch (Exception $ex) {
599 $caught = $ex;
600 } catch (Throwable $ex) {
601 $caught = $ex;
602 }
603
604 $this->popProfilerRule($rule);
605
606 if ($caught) {
607 phlog(pht('An exception occurred executing Herald rule %s: "%s" Review '.
608 'the Herald transcripts and correct or disable the problematic rule.',
609 $rule->getMonogram(),
610 $caught->getMessage()));
611 throw $caught;
612 }
613
614 return $is_match;
615 }
616
617 private function getProfiledObjectFieldValue(
618 HeraldAdapter $adapter,
619 $field_key) {
620
621 // Before engaging the profiler, make sure the field class is loaded.
622
623 $adapter->willGetHeraldField($field_key);
624
625 // The first time we read a field value, we'll actually generate it, which
626 // may be slow.
627
628 // After it is generated for the first time, this will just read it from a
629 // cache, which should be very fast.
630
631 // We still want to profile the request even if it goes to cache so we can
632 // get an accurate count of how many times we access the field value: when
633 // trying to improve the performance of Herald rules, it's helpful to know
634 // how many rules rely on the value of a field which is slow to generate.
635
636 $caught = null;
637
638 $this->pushProfilerField($field_key);
639 try {
640 $value = $this->getObjectFieldValue($field_key);
641 } catch (Exception $ex) {
642 $caught = $ex;
643 } catch (Throwable $ex) {
644 $caught = $ex;
645 }
646 $this->popProfilerField($field_key);
647
648 if ($caught) {
649 throw $caught;
650 }
651
652 return $value;
653 }
654
655 private function getObjectFieldValue($field_key) {
656 if (array_key_exists($field_key, $this->fieldExceptions)) {
657 throw $this->fieldExceptions[$field_key];
658 }
659
660 if (array_key_exists($field_key, $this->fieldCache)) {
661 return $this->fieldCache[$field_key];
662 }
663
664 $adapter = $this->object;
665
666 $caught = null;
667 try {
668 $value = $adapter->getHeraldField($field_key);
669 } catch (Exception $ex) {
670 $caught = $ex;
671 } catch (Throwable $ex) {
672 $caught = $ex;
673 }
674
675 if ($caught) {
676 $this->fieldExceptions[$field_key] = $caught;
677 throw $caught;
678 }
679
680 $this->fieldCache[$field_key] = $value;
681
682 return $value;
683 }
684
685 protected function getRuleEffects(
686 HeraldRule $rule,
687 HeraldAdapter $object) {
688
689 $rule_id = $rule->getID();
690 if (isset($this->skipEffects[$rule_id])) {
691 return array();
692 }
693
694 $effects = array();
695 foreach ($rule->getActions() as $action) {
696 $effect = id(new HeraldEffect())
697 ->setObjectPHID($object->getPHID())
698 ->setAction($action->getAction())
699 ->setTarget($action->getTarget())
700 ->setRule($rule);
701
702 if ($object->getActionImplementation($action->getAction()) === null) {
703 phlog(pht('An exception occurred executing Herald rule %s: Unknown '.
704 'action: "%s". Review the Herald transcripts and correct or '.
705 'disable the problematic rule.',
706 $rule->getMonogram(),
707 $action->getAction()));
708 }
709
710 $name = $rule->getName();
711 $id = $rule->getID();
712 $effect->setReason(
713 pht(
714 'Conditions were met for %s',
715 "H{$id} {$name}"));
716
717 $effects[] = $effect;
718 }
719 return $effects;
720 }
721
722 private function canAuthorViewObject(
723 HeraldRule $rule,
724 HeraldAdapter $adapter) {
725
726 // Authorship is irrelevant for global rules and object rules.
727 if ($rule->isGlobalRule() || $rule->isObjectRule()) {
728 return true;
729 }
730
731 // The author must be able to create rules for the adapter's content type.
732 // In particular, this means that the application must be enabled and
733 // accessible to the user. For example, if a user writes a Differential
734 // rule and then loses access to Differential, this disables the rule.
735 $enabled = HeraldAdapter::getEnabledAdapterMap($rule->getAuthor());
736 if (empty($enabled[$adapter->getAdapterContentType()])) {
737 return false;
738 }
739
740 // Finally, the author must be able to see the object itself. You can't
741 // write a personal rule that CC's you on revisions you wouldn't otherwise
742 // be able to see, for example.
743 $object = $adapter->getObject();
744 return PhabricatorPolicyFilter::hasCapability(
745 $rule->getAuthor(),
746 $object,
747 PhabricatorPolicyCapability::CAN_VIEW);
748 }
749
750 private function canRuleApplyToObject(
751 HeraldRule $rule,
752 HeraldAdapter $adapter) {
753
754 // Rules which are not object rules can apply to anything.
755 if (!$rule->isObjectRule()) {
756 return true;
757 }
758
759 $trigger_phid = $rule->getTriggerObjectPHID();
760 $object_phids = $adapter->getTriggerObjectPHIDs();
761
762 if ($object_phids) {
763 if (in_array($trigger_phid, $object_phids)) {
764 return true;
765 }
766 }
767
768 return false;
769 }
770
771 private function newRuleTranscript(HeraldRule $rule) {
772 $xscript = id(new HeraldRuleTranscript())
773 ->setRuleID($rule->getID())
774 ->setRuleName($rule->getName())
775 ->setRuleOwner($rule->getAuthorPHID());
776
777 $this->transcript->addRuleTranscript($xscript);
778
779 return $xscript;
780 }
781
782 private function newConditionTranscript(
783 HeraldRule $rule,
784 HeraldCondition $condition) {
785
786 $xscript = id(new HeraldConditionTranscript())
787 ->setRuleID($rule->getID())
788 ->setConditionID($condition->getID())
789 ->setFieldName($condition->getFieldName())
790 ->setCondition($condition->getFieldCondition())
791 ->setTestValue($condition->getValue());
792
793 $this->transcript->addConditionTranscript($xscript);
794
795 return $xscript;
796 }
797
798 private function newApplyTranscript(
799 HeraldAdapter $adapter,
800 HeraldRule $rule,
801 HeraldActionRecord $action) {
802
803 $effect = id(new HeraldEffect())
804 ->setObjectPHID($adapter->getPHID())
805 ->setAction($action->getAction())
806 ->setTarget($action->getTarget())
807 ->setRule($rule);
808
809 $xscript = new HeraldApplyTranscript($effect, false);
810
811 $this->transcript->addApplyTranscript($xscript);
812
813 return $xscript;
814 }
815
816 private function isForbidden(
817 HeraldRule $rule,
818 HeraldAdapter $adapter) {
819
820 $forbidden = $adapter->getForbiddenActions();
821 if (!$forbidden) {
822 return false;
823 }
824
825 $forbidden = array_fuse($forbidden);
826
827 $is_forbidden = false;
828
829 foreach ($rule->getConditions() as $condition) {
830 $field_key = $condition->getFieldName();
831
832 if (!isset($this->forbiddenFields[$field_key])) {
833 $reason = null;
834
835 try {
836 $states = $adapter->getRequiredFieldStates($field_key);
837 } catch (Exception $ex) {
838 $states = array();
839 }
840
841 foreach ($states as $state) {
842 if (!isset($forbidden[$state])) {
843 continue;
844 }
845 $reason = $adapter->getForbiddenReason($state);
846 break;
847 }
848
849 $this->forbiddenFields[$field_key] = $reason;
850 }
851
852 $forbidden_reason = $this->forbiddenFields[$field_key];
853 if ($forbidden_reason !== null) {
854 $result_code = HeraldConditionResult::RESULT_OBJECT_STATE;
855 $result_data = array(
856 'reason' => $forbidden_reason,
857 );
858
859 $result = HeraldConditionResult::newFromResultCode($result_code)
860 ->setResultData($result_data);
861
862 $this->newConditionTranscript($rule, $condition)
863 ->setResult($result);
864
865 $is_forbidden = true;
866 }
867 }
868
869 foreach ($rule->getActions() as $action_record) {
870 $action_key = $action_record->getAction();
871
872 if (!isset($this->forbiddenActions[$action_key])) {
873 $reason = null;
874
875 try {
876 $states = $adapter->getRequiredActionStates($action_key);
877 } catch (Exception $ex) {
878 $states = array();
879 }
880
881 foreach ($states as $state) {
882 if (!isset($forbidden[$state])) {
883 continue;
884 }
885 $reason = $adapter->getForbiddenReason($state);
886 break;
887 }
888
889 $this->forbiddenActions[$action_key] = $reason;
890 }
891
892 $forbidden_reason = $this->forbiddenActions[$action_key];
893 if ($forbidden_reason !== null) {
894 $this->newApplyTranscript($adapter, $rule, $action_record)
895 ->setAppliedReason(
896 array(
897 array(
898 'type' => HeraldAction::DO_STANDARD_FORBIDDEN,
899 'data' => $forbidden_reason,
900 ),
901 ));
902
903 $is_forbidden = true;
904 }
905 }
906
907 return $is_forbidden;
908 }
909
910/* -( Profiler )----------------------------------------------------------- */
911
912 private function pushProfilerField($field_key) {
913 return $this->pushProfilerStack('field', $field_key);
914 }
915
916 private function popProfilerField($field_key) {
917 return $this->popProfilerStack('field', $field_key);
918 }
919
920 private function pushProfilerRule(HeraldRule $rule) {
921 return $this->pushProfilerStack('rule', $rule->getPHID());
922 }
923
924 private function popProfilerRule(HeraldRule $rule) {
925 return $this->popProfilerStack('rule', $rule->getPHID());
926 }
927
928 private function pushProfilerStack($type, $key) {
929 $this->profilerStack[] = array(
930 'type' => $type,
931 'key' => $key,
932 'start' => microtime(true),
933 );
934
935 return $this;
936 }
937
938 private function popProfilerStack($type, $key) {
939 if (!$this->profilerStack) {
940 throw new Exception(
941 pht(
942 'Unable to pop profiler stack: profiler stack is empty.'));
943 }
944
945 $frame = last($this->profilerStack);
946 if (($frame['type'] !== $type) || ($frame['key'] !== $key)) {
947 throw new Exception(
948 pht(
949 'Unable to pop profiler stack: expected frame of type "%s" with '.
950 'key "%s", but found frame of type "%s" with key "%s".',
951 $type,
952 $key,
953 $frame['type'],
954 $frame['key']));
955 }
956
957 // Accumulate the new timing information into the existing profile. If this
958 // is the first time we've seen this particular rule or field, we'll
959 // create a new empty frame first.
960
961 $elapsed = microtime(true) - $frame['start'];
962 $frame_key = sprintf('%s/%s', $type, $key);
963
964 if (!isset($this->profilerFrames[$frame_key])) {
965 $current = array(
966 'type' => $type,
967 'key' => $key,
968 'elapsed' => 0,
969 'count' => 0,
970 );
971 } else {
972 $current = $this->profilerFrames[$frame_key];
973 }
974
975 $current['elapsed'] += $elapsed;
976 $current['count']++;
977
978 $this->profilerFrames[$frame_key] = $current;
979
980 array_pop($this->profilerStack);
981 }
982
983 private function getProfile() {
984 if ($this->profilerStack) {
985 $frame = last($this->profilerStack);
986 $frame_type = $frame['type'];
987 $frame_key = $frame['key'];
988 $frame_count = count($this->profilerStack);
989
990 throw new Exception(
991 pht(
992 'Unable to retrieve profile: profiler stack is not empty. The '.
993 'stack has %s frame(s); the final frame has type "%s" and key '.
994 '"%s".',
995 new PhutilNumber($frame_count),
996 $frame_type,
997 $frame_key));
998 }
999
1000 return array_values($this->profilerFrames);
1001 }
1002
1003
1004}