@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 HeraldRuleController extends HeraldController {
4
5 public function handleRequest(AphrontRequest $request) {
6 $viewer = $request->getViewer();
7 $id = $request->getURIData('id');
8
9 $content_type_map = HeraldAdapter::getEnabledAdapterMap($viewer);
10 $rule_type_map = HeraldRuleTypeConfig::getRuleTypeMap();
11
12 if ($id) {
13 $rule = id(new HeraldRuleQuery())
14 ->setViewer($viewer)
15 ->withIDs(array($id))
16 ->requireCapabilities(
17 array(
18 PhabricatorPolicyCapability::CAN_VIEW,
19 PhabricatorPolicyCapability::CAN_EDIT,
20 ))
21 ->executeOne();
22 if (!$rule) {
23 return new Aphront404Response();
24 }
25 $cancel_uri = '/'.$rule->getMonogram();
26 } else {
27 $new_uri = $this->getApplicationURI('new/');
28
29 $rule = new HeraldRule();
30 $rule->setAuthorPHID($viewer->getPHID());
31 $rule->setMustMatchAll(1);
32
33 $content_type = $request->getStr('content_type');
34 $rule->setContentType($content_type);
35
36 $rule_type = $request->getStr('rule_type');
37 if (!isset($rule_type_map[$rule_type])) {
38 return $this->newDialog()
39 ->setTitle(pht('Invalid Rule Type'))
40 ->appendParagraph(
41 pht(
42 'The selected rule type ("%s") is not recognized by Herald.',
43 $rule_type))
44 ->addCancelButton($new_uri);
45 }
46 $rule->setRuleType($rule_type);
47
48 try {
49 $adapter = HeraldAdapter::getAdapterForContentType(
50 $rule->getContentType());
51 } catch (Exception $ex) {
52 return $this->newDialog()
53 ->setTitle(pht('Invalid Content Type'))
54 ->appendParagraph(
55 pht(
56 'The selected content type ("%s") is not recognized by '.
57 'Herald.',
58 $rule->getContentType()))
59 ->addCancelButton($new_uri);
60 }
61
62 if (!$adapter->supportsRuleType($rule->getRuleType())) {
63 return $this->newDialog()
64 ->setTitle(pht('Rule/Content Mismatch'))
65 ->appendParagraph(
66 pht(
67 'The selected rule type ("%s") is not supported by the selected '.
68 'content type ("%s").',
69 $rule->getRuleType(),
70 $rule->getContentType()))
71 ->addCancelButton($new_uri);
72 }
73
74 if ($rule->isObjectRule()) {
75 $rule->setTriggerObjectPHID($request->getStr('targetPHID'));
76 $object = id(new PhabricatorObjectQuery())
77 ->setViewer($viewer)
78 ->withPHIDs(array($rule->getTriggerObjectPHID()))
79 ->requireCapabilities(
80 array(
81 PhabricatorPolicyCapability::CAN_VIEW,
82 PhabricatorPolicyCapability::CAN_EDIT,
83 ))
84 ->executeOne();
85 if (!$object) {
86 throw new Exception(
87 pht('No valid object provided for object rule!'));
88 }
89
90 if (!$adapter->canTriggerOnObject($object)) {
91 throw new Exception(
92 pht('Object is of wrong type for adapter!'));
93 }
94 }
95
96 $cancel_uri = $this->getApplicationURI();
97 }
98
99 if ($rule->isGlobalRule()) {
100 $this->requireApplicationCapability(
101 HeraldManageGlobalRulesCapability::CAPABILITY);
102 }
103
104 $adapter = HeraldAdapter::getAdapterForContentType($rule->getContentType());
105
106 $local_version = id(new HeraldRule())->getConfigVersion();
107 if ($rule->getConfigVersion() > $local_version) {
108 throw new Exception(
109 pht(
110 'This rule was created with a newer version of Herald. You can not '.
111 'view or edit it in this older version. Upgrade your software.'));
112 }
113
114 // Upgrade rule version to our version, since we might add newly-defined
115 // conditions, etc.
116 $rule->setConfigVersion($local_version);
117
118 $rule_conditions = $rule->loadConditions();
119 $rule_actions = $rule->loadActions();
120
121 $rule->attachConditions($rule_conditions);
122 $rule->attachActions($rule_actions);
123
124 $e_name = true;
125 $errors = array();
126 if ($request->isFormPost() && $request->getStr('save')) {
127 list($e_name, $errors) = $this->saveRule($adapter, $rule, $request);
128 if (!$errors) {
129 $id = $rule->getID();
130 $uri = '/'.$rule->getMonogram();
131 return id(new AphrontRedirectResponse())->setURI($uri);
132 }
133 }
134
135 $must_match_selector = $this->renderMustMatchSelector($rule);
136 $repetition_selector = $this->renderRepetitionSelector($rule, $adapter);
137
138 $handles = $this->loadHandlesForRule($rule);
139
140 require_celerity_resource('herald-css');
141
142 $content_type_name = $content_type_map[$rule->getContentType()];
143 $rule_type_name = $rule_type_map[$rule->getRuleType()];
144
145 $form = id(new AphrontFormView())
146 ->setUser($viewer)
147 ->setID('herald-rule-edit-form')
148 ->addHiddenInput('content_type', $rule->getContentType())
149 ->addHiddenInput('rule_type', $rule->getRuleType())
150 ->addHiddenInput('save', 1)
151 ->appendChild(
152 // Build this explicitly (instead of using addHiddenInput())
153 // so we can add a sigil to it.
154 javelin_tag(
155 'input',
156 array(
157 'type' => 'hidden',
158 'name' => 'rule',
159 'sigil' => 'rule',
160 )))
161 ->appendChild(
162 id(new AphrontFormTextControl())
163 ->setLabel(pht('Rule Name'))
164 ->setName('name')
165 ->setError($e_name)
166 ->setValue($rule->getName()));
167
168 $trigger_object_control = false;
169 if ($rule->isObjectRule()) {
170 $trigger_object_control = id(new AphrontFormStaticControl())
171 ->setValue(
172 pht(
173 'This rule triggers for %s.',
174 $handles[$rule->getTriggerObjectPHID()]->renderLink()));
175 }
176
177
178 $form
179 ->appendChild(
180 id(new AphrontFormMarkupControl())
181 ->setValue(pht(
182 'This %s rule triggers for %s.',
183 phutil_tag('strong', array(), $rule_type_name),
184 phutil_tag('strong', array(), $content_type_name))))
185 ->appendChild($trigger_object_control)
186 ->appendChild(
187 id(new PHUIFormInsetView())
188 ->setTitle(pht('Conditions'))
189 ->setRightButton(javelin_tag(
190 'a',
191 array(
192 'href' => '#',
193 'class' => 'button button-green',
194 'sigil' => 'create-condition',
195 'mustcapture' => true,
196 ),
197 pht('New Condition')))
198 ->setDescription(
199 pht('When %s these conditions are met:', $must_match_selector))
200 ->setContent(javelin_tag(
201 'table',
202 array(
203 'sigil' => 'rule-conditions',
204 'class' => 'herald-condition-table',
205 ),
206 '')))
207 ->appendChild(
208 id(new PHUIFormInsetView())
209 ->setTitle(pht('Action'))
210 ->setRightButton(javelin_tag(
211 'a',
212 array(
213 'href' => '#',
214 'class' => 'button button-green',
215 'sigil' => 'create-action',
216 'mustcapture' => true,
217 ),
218 pht('New Action')))
219 ->setDescription(pht(
220 'Take these actions %s',
221 $repetition_selector))
222 ->setContent(javelin_tag(
223 'table',
224 array(
225 'sigil' => 'rule-actions',
226 'class' => 'herald-action-table',
227 ),
228 '')))
229 ->appendChild(
230 id(new AphrontFormSubmitControl())
231 ->setValue(pht('Save Rule'))
232 ->addCancelButton($cancel_uri));
233
234 $this->setupEditorBehavior($rule, $handles, $adapter);
235
236 $title = $rule->getID()
237 ? pht('Edit Herald Rule: %s', $rule->getName())
238 : pht('Create Herald Rule: %s', idx($content_type_map, $content_type));
239
240 $form_box = id(new PHUIObjectBoxView())
241 ->setHeaderText($title)
242 ->setBackground(PHUIObjectBoxView::WHITE_CONFIG)
243 ->setFormErrors($errors)
244 ->setForm($form);
245
246 $crumbs = $this
247 ->buildApplicationCrumbs()
248 ->addTextCrumb($title)
249 ->setBorder(true);
250
251 $view = id(new PHUITwoColumnView())
252 ->setFooter($form_box);
253
254 return $this->newPage()
255 ->setTitle($title)
256 ->setCrumbs($crumbs)
257 ->appendChild(
258 array(
259 $view,
260 ));
261 }
262
263 private function saveRule(HeraldAdapter $adapter, $rule, $request) {
264 $new_name = $request->getStr('name');
265 $match_all = ($request->getStr('must_match') == 'all');
266
267 $repetition_policy = $request->getStr('repetition_policy');
268
269 // If the user selected an invalid policy, or there's only one possible
270 // value so we didn't render a control, adjust the value to the first
271 // valid policy value.
272 $repetition_options = $this->getRepetitionOptionMap($adapter);
273 if (!$repetition_policy ||
274 !isset($repetition_options[$repetition_policy])) {
275 $repetition_policy = head_key($repetition_options);
276 }
277
278 $e_name = true;
279 $errors = array();
280
281 if (!strlen($new_name)) {
282 $e_name = pht('Required');
283 $errors[] = pht('Rule must have a name.');
284 }
285
286 $data = null;
287 try {
288 $data = phutil_json_decode($request->getStr('rule'));
289 } catch (PhutilJSONParserException $ex) {
290 throw new Exception(
291 pht('Failed to decode rule data.'),
292 0,
293 $ex);
294 }
295
296 if (!is_array($data) ||
297 !$data['conditions'] ||
298 !$data['actions']) {
299 throw new Exception(pht('Failed to decode rule data.'));
300 }
301
302 $conditions = array();
303 foreach ($data['conditions'] as $condition) {
304 if ($condition === null) {
305 // We manage this as a sparse array on the client, so may receive
306 // NULL if conditions have been removed.
307 continue;
308 }
309
310 $obj = new HeraldCondition();
311 $obj->setFieldName($condition[0]);
312 $obj->setFieldCondition($condition[1]);
313
314 if (is_array($condition[2])) {
315 $obj->setValue(array_keys($condition[2]));
316 } else {
317 $obj->setValue($condition[2]);
318 }
319
320 try {
321 $adapter->willSaveCondition($obj);
322 } catch (HeraldInvalidConditionException $ex) {
323 $errors[] = $ex->getMessage();
324 }
325
326 $conditions[] = $obj;
327 }
328
329 $actions = array();
330 foreach ($data['actions'] as $action) {
331 if ($action === null) {
332 // Sparse on the client; removals can give us NULLs.
333 continue;
334 }
335
336 if (!isset($action[1])) {
337 // Legitimate for any action which doesn't need a target, like
338 // "Do nothing".
339 $action[1] = null;
340 }
341
342 $obj = new HeraldActionRecord();
343 $obj->setAction($action[0]);
344 $obj->setTarget($action[1]);
345
346 try {
347 $adapter->willSaveAction($rule, $obj);
348 } catch (HeraldInvalidActionException $ex) {
349 $errors[] = $ex->getMessage();
350 }
351
352 $actions[] = $obj;
353 }
354
355 if (!$errors) {
356 $new_state = id(new HeraldRuleSerializer())->serializeRuleComponents(
357 $match_all,
358 $conditions,
359 $actions,
360 $repetition_policy);
361
362 $xactions = array();
363
364 // Until this moves to EditEngine, manually add a "CREATE" transaction
365 // if we're creating a new rule. This improves rendering of the initial
366 // group of transactions.
367 $is_new = (bool)(!$rule->getID());
368 if ($is_new) {
369 $xactions[] = id(new HeraldRuleTransaction())
370 ->setTransactionType(PhabricatorTransactions::TYPE_CREATE);
371 }
372
373 $xactions[] = id(new HeraldRuleTransaction())
374 ->setTransactionType(HeraldRuleEditTransaction::TRANSACTIONTYPE)
375 ->setNewValue($new_state);
376 $xactions[] = id(new HeraldRuleTransaction())
377 ->setTransactionType(HeraldRuleNameTransaction::TRANSACTIONTYPE)
378 ->setNewValue($new_name);
379
380 try {
381 id(new HeraldRuleEditor())
382 ->setActor($this->getViewer())
383 ->setContinueOnNoEffect(true)
384 ->setContentSourceFromRequest($request)
385 ->applyTransactions($rule, $xactions);
386 return array(null, null);
387 } catch (Exception $ex) {
388 $errors[] = $ex->getMessage();
389 }
390 }
391
392 // mutate current rule, so it would be sent to the client in the right state
393 $rule->setMustMatchAll((int)$match_all);
394 $rule->setName($new_name);
395 $rule->setRepetitionPolicyStringConstant($repetition_policy);
396 $rule->attachConditions($conditions);
397 $rule->attachActions($actions);
398
399 return array($e_name, $errors);
400 }
401
402 private function setupEditorBehavior(
403 HeraldRule $rule,
404 array $handles,
405 HeraldAdapter $adapter) {
406
407 $all_rules = $this->loadRulesThisRuleMayDependUpon($rule);
408 $all_rules = msortv($all_rules, 'getEditorSortVector');
409 $all_rules = mpull($all_rules, 'getEditorDisplayName', 'getPHID');
410
411 $all_fields = $adapter->getFieldNameMap();
412 $all_conditions = $adapter->getConditionNameMap();
413 $all_actions = $adapter->getActionNameMap($rule->getRuleType());
414
415 $fields = $adapter->getFields();
416 $field_map = array_select_keys($all_fields, $fields);
417
418 // Populate any fields which exist in the rule but which we don't know the
419 // names of, so that saving a rule without touching anything doesn't change
420 // it.
421 foreach ($rule->getConditions() as $condition) {
422 $field_name = $condition->getFieldName();
423
424 if (empty($field_map[$field_name])) {
425 $field_map[$field_name] = pht('<Unknown Field "%s">', $field_name);
426 }
427 }
428
429 $actions = $adapter->getActions($rule->getRuleType());
430 $action_map = array_select_keys($all_actions, $actions);
431
432 // Populate any actions which exist in the rule but which we don't know the
433 // names of, so that saving a rule without touching anything doesn't change
434 // it.
435 foreach ($rule->getActions() as $action) {
436 $action_name = $action->getAction();
437
438 if (empty($action_map[$action_name])) {
439 $action_map[$action_name] = pht('<Unknown Action "%s">', $action_name);
440 }
441 }
442
443 $config_info = array();
444 $config_info['fields'] = $this->getFieldGroups($adapter, $field_map);
445 $config_info['conditions'] = $all_conditions;
446 $config_info['actions'] = $this->getActionGroups($adapter, $action_map);
447 $config_info['valueMap'] = array();
448
449 foreach ($field_map as $field => $name) {
450 try {
451 $field_conditions = $adapter->getConditionsForField($field);
452 } catch (Exception $ex) {
453 $field_conditions = array(HeraldAdapter::CONDITION_UNCONDITIONALLY);
454 }
455 $config_info['conditionMap'][$field] = $field_conditions;
456 }
457
458 foreach ($field_map as $field => $fname) {
459 foreach ($config_info['conditionMap'][$field] as $condition) {
460 $value_key = $adapter->getValueTypeForFieldAndCondition(
461 $field,
462 $condition);
463
464 if ($value_key instanceof HeraldFieldValue) {
465 $value_key->setViewer($this->getViewer());
466
467 $spec = $value_key->getControlSpecificationDictionary();
468 $value_key = $value_key->getFieldValueKey();
469 $config_info['valueMap'][$value_key] = $spec;
470 }
471
472 $config_info['values'][$field][$condition] = $value_key;
473 }
474 }
475
476 $config_info['rule_type'] = $rule->getRuleType();
477
478 foreach ($action_map as $action => $name) {
479 try {
480 $value_key = $adapter->getValueTypeForAction(
481 $action,
482 $rule->getRuleType());
483 } catch (Exception $ex) {
484 $value_key = new HeraldEmptyFieldValue();
485 }
486
487 if ($value_key instanceof HeraldFieldValue) {
488 $value_key->setViewer($this->getViewer());
489
490 $spec = $value_key->getControlSpecificationDictionary();
491 $value_key = $value_key->getFieldValueKey();
492 $config_info['valueMap'][$value_key] = $spec;
493 }
494
495 $config_info['targets'][$action] = $value_key;
496 }
497
498 $default_group = head($config_info['fields']);
499 $default_field = head_key($default_group['options']);
500 $default_condition = head($config_info['conditionMap'][$default_field]);
501 $default_actions = head($config_info['actions']);
502 $default_action = head_key($default_actions['options']);
503
504 if ($rule->getConditions()) {
505 $serial_conditions = array();
506 foreach ($rule->getConditions() as $condition) {
507 $value = $adapter->getEditorValueForCondition(
508 $this->getViewer(),
509 $condition);
510
511 $serial_conditions[] = array(
512 $condition->getFieldName(),
513 $condition->getFieldCondition(),
514 $value,
515 );
516 }
517 } else {
518 $serial_conditions = array(
519 array($default_field, $default_condition, null),
520 );
521 }
522
523 if ($rule->getActions()) {
524 $serial_actions = array();
525 foreach ($rule->getActions() as $action) {
526 $value = $adapter->getEditorValueForAction(
527 $this->getViewer(),
528 $action);
529
530 $serial_actions[] = array(
531 $action->getAction(),
532 $value,
533 );
534 }
535 } else {
536 $serial_actions = array(
537 array($default_action, null),
538 );
539 }
540
541 Javelin::initBehavior(
542 'herald-rule-editor',
543 array(
544 'root' => 'herald-rule-edit-form',
545 'default' => array(
546 'field' => $default_field,
547 'condition' => $default_condition,
548 'action' => $default_action,
549 ),
550 'conditions' => (object)$serial_conditions,
551 'actions' => (object)$serial_actions,
552 'template' => $this->buildTokenizerTemplates() + array(
553 'rules' => $all_rules,
554 ),
555 'info' => $config_info,
556 ));
557 }
558
559 private function loadHandlesForRule($rule) {
560 $phids = array();
561
562 foreach ($rule->getActions() as $action) {
563 if (!is_array($action->getTarget())) {
564 continue;
565 }
566 foreach ($action->getTarget() as $target) {
567 $target = (array)$target;
568 foreach ($target as $phid) {
569 $phids[] = $phid;
570 }
571 }
572 }
573
574 foreach ($rule->getConditions() as $condition) {
575 $value = $condition->getValue();
576 if (is_array($value)) {
577 foreach ($value as $phid) {
578 $phids[] = $phid;
579 }
580 }
581 }
582
583 $phids[] = $rule->getAuthorPHID();
584
585 if ($rule->isObjectRule()) {
586 $phids[] = $rule->getTriggerObjectPHID();
587 }
588
589 return $this->loadViewerHandles($phids);
590 }
591
592
593 /**
594 * Render the selector for the "When (all of | any of) these conditions are
595 * met:" element.
596 */
597 private function renderMustMatchSelector($rule) {
598 return AphrontFormSelectControl::renderSelectTag(
599 $rule->getMustMatchAll() ? 'all' : 'any',
600 array(
601 'all' => pht('all of'),
602 'any' => pht('any of'),
603 ),
604 array(
605 'name' => 'must_match',
606 ));
607 }
608
609
610 /**
611 * Render the selector for "Take these actions (every time | only the first
612 * time) this rule matches..." element.
613 */
614 private function renderRepetitionSelector($rule, HeraldAdapter $adapter) {
615 $repetition_policy = $rule->getRepetitionPolicyStringConstant();
616 $repetition_map = $this->getRepetitionOptionMap($adapter);
617 if (count($repetition_map) < 2) {
618 return head($repetition_map);
619 } else {
620 return AphrontFormSelectControl::renderSelectTag(
621 $repetition_policy,
622 $repetition_map,
623 array(
624 'name' => 'repetition_policy',
625 ));
626 }
627 }
628
629 private function getRepetitionOptionMap(HeraldAdapter $adapter) {
630 $repetition_options = $adapter->getRepetitionOptions();
631 $repetition_names = HeraldRule::getRepetitionPolicySelectOptionMap();
632 return array_select_keys($repetition_names, $repetition_options);
633 }
634
635 protected function buildTokenizerTemplates() {
636 $template = new AphrontTokenizerTemplateView();
637 $template = $template->render();
638 return array(
639 'markup' => $template,
640 );
641 }
642
643
644 /**
645 * Load rules for the "Another Herald rule..." condition dropdown, which
646 * allows one rule to depend upon the success or failure of another rule.
647 */
648 private function loadRulesThisRuleMayDependUpon(HeraldRule $rule) {
649 $viewer = $this->getRequest()->getUser();
650
651 // Any rule can depend on a global rule.
652 $all_rules = id(new HeraldRuleQuery())
653 ->setViewer($viewer)
654 ->withRuleTypes(array(HeraldRuleTypeConfig::RULE_TYPE_GLOBAL))
655 ->withContentTypes(array($rule->getContentType()))
656 ->execute();
657
658 if ($rule->isObjectRule()) {
659 // Object rules may depend on other rules for the same object.
660 $all_rules += id(new HeraldRuleQuery())
661 ->setViewer($viewer)
662 ->withRuleTypes(array(HeraldRuleTypeConfig::RULE_TYPE_OBJECT))
663 ->withContentTypes(array($rule->getContentType()))
664 ->withTriggerObjectPHIDs(array($rule->getTriggerObjectPHID()))
665 ->execute();
666 }
667
668 if ($rule->isPersonalRule()) {
669 // Personal rules may depend upon your other personal rules.
670 $all_rules += id(new HeraldRuleQuery())
671 ->setViewer($viewer)
672 ->withRuleTypes(array(HeraldRuleTypeConfig::RULE_TYPE_PERSONAL))
673 ->withContentTypes(array($rule->getContentType()))
674 ->withAuthorPHIDs(array($rule->getAuthorPHID()))
675 ->execute();
676 }
677
678 // A rule can not depend upon itself.
679 unset($all_rules[$rule->getID()]);
680
681 return $all_rules;
682 }
683
684 private function getFieldGroups(HeraldAdapter $adapter, array $field_map) {
685 $group_map = array();
686 foreach ($field_map as $field_key => $field_name) {
687 $group_key = $adapter->getFieldGroupKey($field_key);
688 $group_map[$group_key][$field_key] = array(
689 'name' => $field_name,
690 'available' => $adapter->isFieldAvailable($field_key),
691 );
692 }
693
694 return $this->getGroups(
695 $group_map,
696 HeraldFieldGroup::getAllFieldGroups());
697 }
698
699 private function getActionGroups(HeraldAdapter $adapter, array $action_map) {
700 $group_map = array();
701 foreach ($action_map as $action_key => $action_name) {
702 $group_key = $adapter->getActionGroupKey($action_key);
703 $group_map[$group_key][$action_key] = array(
704 'name' => $action_name,
705 'available' => $adapter->isActionAvailable($action_key),
706 );
707 }
708
709 return $this->getGroups(
710 $group_map,
711 HeraldActionGroup::getAllActionGroups());
712 }
713
714 /**
715 * @param array $item_map
716 * @param array<HeraldGroup> $group_list
717 */
718 private function getGroups(array $item_map, array $group_list) {
719 assert_instances_of($group_list, HeraldGroup::class);
720
721 $groups = array();
722 foreach ($item_map as $group_key => $options) {
723 asort($options);
724
725 $group_object = idx($group_list, $group_key);
726 if ($group_object) {
727 $group_label = $group_object->getGroupLabel();
728 $group_order = $group_object->getSortKey();
729 } else {
730 $group_label = nonempty($group_key, pht('Other'));
731 $group_order = 'Z';
732 }
733
734 $groups[] = array(
735 'label' => $group_label,
736 'options' => $options,
737 'order' => $group_order,
738 );
739 }
740
741 return array_values(isort($groups, 'order'));
742 }
743
744
745}