5 * Publishing and Managing State
8 * After applying changes, the Editor queues a worker to publish mail, feed,
9 * and notifications, and to perform other background work like updating search
10 * indexes. This allows it to do this work without impacting performance for
13 * When work is moved to the daemons, the Editor state is serialized by
14 * @{method:getWorkerState}, then reloaded in a daemon process by
15 * @{method:loadWorkerState}. **This is fragile.**
17 * State is not persisted into the daemons by default, because we can not send
18 * arbitrary objects into the queue. This means the default behavior of any
19 * state properties is to reset to their defaults without warning prior to
22 * The easiest way to avoid this is to keep Editors stateless: the overwhelming
23 * majority of Editors can be written statelessly. If you need to maintain
24 * state, you can either:
26 * - not require state to exist during publishing; or
27 * - pass state to the daemons by implementing @{method:getCustomWorkerState}
28 * and @{method:loadCustomWorkerState}.
30 * This architecture isn't ideal, and we may eventually split this class into
31 * "Editor" and "Publisher" parts to make it more robust. See T6367 for some
32 * discussion and context.
34 * @task mail Sending Mail
35 * @task feed Publishing Feed Stories
36 * @task search Search Index
37 * @task files Integration with Files
38 * @task workers Managing Workers
40 abstract class PhabricatorApplicationTransactionEditor
41 extends PhabricatorEditor
{
43 private $contentSource;
48 private $mentionedPHIDs;
49 private $continueOnNoEffect;
50 private $continueOnMissingFields;
51 private $raiseWarnings;
52 private $parentMessageID;
53 private $heraldAdapter;
54 private $heraldTranscript;
56 private $unmentionablePHIDMap = array();
57 private $transactionGroupID;
58 private $applicationEmail;
61 private $isHeraldEditor;
62 private $isInverseEdgeEditor;
63 private $actingAsPHID;
65 private $heraldEmailPHIDs = array();
66 private $heraldForcedEmailPHIDs = array();
67 private $heraldHeader;
68 private $mailToPHIDs = array();
69 private $mailCCPHIDs = array();
70 private $feedNotifyPHIDs = array();
71 private $feedRelatedPHIDs = array();
72 private $feedShouldPublish = false;
73 private $mailShouldSend = false;
74 private $modularTypes;
76 private $mustEncrypt = array();
77 private $stampTemplates = array();
78 private $mailStamps = array();
79 private $oldTo = array();
80 private $oldCC = array();
81 private $mailRemovedPHIDs = array();
82 private $mailUnexpandablePHIDs = array();
83 private $mailMutedPHIDs = array();
84 private $webhookMap = array();
86 private $transactionQueue = array();
87 private $sendHistory = false;
88 private $shouldRequireMFA = false;
89 private $hasRequiredMFA = false;
94 private $parentEditor;
95 private $subEditors = array();
96 private $publishableObject;
97 private $publishableTransactions;
99 const STORAGE_ENCODING_BINARY
= 'binary';
102 * Get the class name for the application this editor is a part of.
104 * Uninstalling the application will disable the editor.
106 * @return string Editor's application class name.
108 abstract public function getEditorApplicationClass();
112 * Get a description of the objects this editor edits, like "Differential
115 * @return string Human readable description of edited objects.
117 abstract public function getEditorObjectsDescription();
120 public function setActingAsPHID($acting_as_phid) {
121 $this->actingAsPHID
= $acting_as_phid;
125 public function getActingAsPHID() {
126 if ($this->actingAsPHID
) {
127 return $this->actingAsPHID
;
129 return $this->getActor()->getPHID();
134 * When the editor tries to apply transactions that have no effect, should
135 * it raise an exception (default) or drop them and continue?
137 * Generally, you will set this flag for edits coming from "Edit" interfaces,
138 * and leave it cleared for edits coming from "Comment" interfaces, so the
139 * user will get a useful error if they try to submit a comment that does
140 * nothing (e.g., empty comment with a status change that has already been
141 * performed by another user).
143 * @param bool True to drop transactions without effect and continue.
146 public function setContinueOnNoEffect($continue) {
147 $this->continueOnNoEffect
= $continue;
151 public function getContinueOnNoEffect() {
152 return $this->continueOnNoEffect
;
157 * When the editor tries to apply transactions which don't populate all of
158 * an object's required fields, should it raise an exception (default) or
159 * drop them and continue?
161 * For example, if a user adds a new required custom field (like "Severity")
162 * to a task, all existing tasks won't have it populated. When users
163 * manually edit existing tasks, it's usually desirable to have them provide
164 * a severity. However, other operations (like batch editing just the
165 * owner of a task) will fail by default.
167 * By setting this flag for edit operations which apply to specific fields
168 * (like the priority, batch, and merge editors in Maniphest), these
169 * operations can continue to function even if an object is outdated.
171 * @param bool True to continue when transactions don't completely satisfy
172 * all required fields.
175 public function setContinueOnMissingFields($continue_on_missing_fields) {
176 $this->continueOnMissingFields
= $continue_on_missing_fields;
180 public function getContinueOnMissingFields() {
181 return $this->continueOnMissingFields
;
186 * Not strictly necessary, but reply handlers ideally set this value to
187 * make email threading work better.
189 public function setParentMessageID($parent_message_id) {
190 $this->parentMessageID
= $parent_message_id;
193 public function getParentMessageID() {
194 return $this->parentMessageID
;
197 public function getIsNewObject() {
198 return $this->isNewObject
;
201 public function getMentionedPHIDs() {
202 return $this->mentionedPHIDs
;
205 public function setIsPreview($is_preview) {
206 $this->isPreview
= $is_preview;
210 public function getIsPreview() {
211 return $this->isPreview
;
214 public function setIsSilent($silent) {
215 $this->silent
= $silent;
219 public function getIsSilent() {
220 return $this->silent
;
223 public function getMustEncrypt() {
224 return $this->mustEncrypt
;
227 public function getHeraldRuleMonograms() {
228 // Convert the stored "<123>, <456>" string into a list: "H123", "H456".
229 $list = phutil_string_cast($this->heraldHeader
);
230 $list = preg_split('/[, ]+/', $list);
232 foreach ($list as $key => $item) {
233 $item = trim($item, '<>');
235 if (!is_numeric($item)) {
240 $list[$key] = 'H'.$item;
246 public function setIsInverseEdgeEditor($is_inverse_edge_editor) {
247 $this->isInverseEdgeEditor
= $is_inverse_edge_editor;
251 public function getIsInverseEdgeEditor() {
252 return $this->isInverseEdgeEditor
;
255 public function setIsHeraldEditor($is_herald_editor) {
256 $this->isHeraldEditor
= $is_herald_editor;
260 public function getIsHeraldEditor() {
261 return $this->isHeraldEditor
;
264 public function addUnmentionablePHIDs(array $phids) {
265 foreach ($phids as $phid) {
266 $this->unmentionablePHIDMap
[$phid] = true;
271 private function getUnmentionablePHIDMap() {
272 return $this->unmentionablePHIDMap
;
275 protected function shouldEnableMentions(
276 PhabricatorLiskDAO
$object,
281 public function setApplicationEmail(
282 PhabricatorMetaMTAApplicationEmail
$email) {
283 $this->applicationEmail
= $email;
287 public function getApplicationEmail() {
288 return $this->applicationEmail
;
291 public function setRaiseWarnings($raise_warnings) {
292 $this->raiseWarnings
= $raise_warnings;
296 public function getRaiseWarnings() {
297 return $this->raiseWarnings
;
300 public function setShouldRequireMFA($should_require_mfa) {
301 if ($this->hasRequiredMFA
) {
304 'Call to setShouldRequireMFA() is too late: this Editor has already '.
305 'checked for MFA requirements.'));
308 $this->shouldRequireMFA
= $should_require_mfa;
312 public function getShouldRequireMFA() {
313 return $this->shouldRequireMFA
;
316 public function getTransactionTypesForObject($object) {
317 $old = $this->object;
319 $this->object = $object;
320 $result = $this->getTransactionTypes();
321 $this->object = $old;
322 } catch (Exception
$ex) {
323 $this->object = $old;
329 public function getTransactionTypes() {
332 $types[] = PhabricatorTransactions
::TYPE_CREATE
;
333 $types[] = PhabricatorTransactions
::TYPE_HISTORY
;
335 $types[] = PhabricatorTransactions
::TYPE_FILE
;
337 if ($this->object instanceof PhabricatorEditEngineSubtypeInterface
) {
338 $types[] = PhabricatorTransactions
::TYPE_SUBTYPE
;
341 if ($this->object instanceof PhabricatorSubscribableInterface
) {
342 $types[] = PhabricatorTransactions
::TYPE_SUBSCRIBERS
;
345 if ($this->object instanceof PhabricatorCustomFieldInterface
) {
346 $types[] = PhabricatorTransactions
::TYPE_CUSTOMFIELD
;
349 if ($this->object instanceof PhabricatorTokenReceiverInterface
) {
350 $types[] = PhabricatorTransactions
::TYPE_TOKEN
;
353 if ($this->object instanceof PhabricatorProjectInterface ||
354 $this->object instanceof PhabricatorMentionableInterface
) {
355 $types[] = PhabricatorTransactions
::TYPE_EDGE
;
358 if ($this->object instanceof PhabricatorSpacesInterface
) {
359 $types[] = PhabricatorTransactions
::TYPE_SPACE
;
362 $types[] = PhabricatorTransactions
::TYPE_MFA
;
364 $template = $this->object->getApplicationTransactionTemplate();
365 if ($template instanceof PhabricatorModularTransaction
) {
366 $xtypes = $template->newModularTransactionTypes();
367 foreach ($xtypes as $xtype) {
368 $types[] = $xtype->getTransactionTypeConstant();
373 $comment = $template->getApplicationTransactionCommentObject();
375 $types[] = PhabricatorTransactions
::TYPE_COMMENT
;
382 private function adjustTransactionValues(
383 PhabricatorLiskDAO
$object,
384 PhabricatorApplicationTransaction
$xaction) {
386 if ($xaction->shouldGenerateOldValue()) {
387 $old = $this->getTransactionOldValue($object, $xaction);
388 $xaction->setOldValue($old);
391 $new = $this->getTransactionNewValue($object, $xaction);
392 $xaction->setNewValue($new);
394 // Apply an optional transformation to convert "external" tranaction
395 // values (provided by APIs) into "internal" values.
397 $old = $xaction->getOldValue();
398 $new = $xaction->getNewValue();
400 $type = $xaction->getTransactionType();
401 $xtype = $this->getModularTransactionType($type);
403 $xtype = clone $xtype;
404 $xtype->setStorage($xaction);
407 // TODO: Provide a modular hook for modern transactions to do a
409 list($old, $new) = array($old, $new);
414 case PhabricatorTransactions
::TYPE_FILE
:
415 list($old, $new) = $this->newFileTransactionInternalValues(
424 $xaction->setOldValue($old);
425 $xaction->setNewValue($new);
428 private function newFileTransactionInternalValues(
429 PhabricatorLiskDAO
$object,
430 PhabricatorApplicationTransaction
$xaction,
436 if (!$this->getIsNewObject()) {
437 $phid = $object->getPHID();
439 $attachment_table = new PhabricatorFileAttachment();
440 $attachment_conn = $attachment_table->establishConnection('w');
444 'SELECT filePHID, attachmentMode FROM %R WHERE objectPHID = %s',
447 $old_map = ipull($rows, 'attachmentMode', 'filePHID');
452 foreach ($new as $file_phid => $attachment_mode) {
453 if ($attachment_mode == PhabricatorFileAttachment
::MODE_DETACH
) {
454 unset($new_map[$file_phid]);
458 $new_map[$file_phid] = $attachment_mode;
461 foreach (array_keys($old_map +
$new_map) as $key) {
462 if (isset($old_map[$key]) && isset($new_map[$key])) {
463 if ($old_map[$key] === $new_map[$key]) {
464 unset($old_map[$key]);
465 unset($new_map[$key]);
470 return array($old_map, $new_map);
473 private function getTransactionOldValue(
474 PhabricatorLiskDAO
$object,
475 PhabricatorApplicationTransaction
$xaction) {
477 $type = $xaction->getTransactionType();
479 $xtype = $this->getModularTransactionType($type);
481 $xtype = clone $xtype;
482 $xtype->setStorage($xaction);
483 return $xtype->generateOldValue($object);
487 case PhabricatorTransactions
::TYPE_CREATE
:
488 case PhabricatorTransactions
::TYPE_HISTORY
:
490 case PhabricatorTransactions
::TYPE_SUBTYPE
:
491 return $object->getEditEngineSubtype();
492 case PhabricatorTransactions
::TYPE_MFA
:
494 case PhabricatorTransactions
::TYPE_SUBSCRIBERS
:
495 return array_values($this->subscribers
);
496 case PhabricatorTransactions
::TYPE_VIEW_POLICY
:
497 if ($this->getIsNewObject()) {
500 return $object->getViewPolicy();
501 case PhabricatorTransactions
::TYPE_EDIT_POLICY
:
502 if ($this->getIsNewObject()) {
505 return $object->getEditPolicy();
506 case PhabricatorTransactions
::TYPE_JOIN_POLICY
:
507 if ($this->getIsNewObject()) {
510 return $object->getJoinPolicy();
511 case PhabricatorTransactions
::TYPE_INTERACT_POLICY
:
512 if ($this->getIsNewObject()) {
515 return $object->getInteractPolicy();
516 case PhabricatorTransactions
::TYPE_SPACE
:
517 if ($this->getIsNewObject()) {
521 $space_phid = $object->getSpacePHID();
522 if ($space_phid === null) {
523 $default_space = PhabricatorSpacesNamespaceQuery
::getDefaultSpace();
524 if ($default_space) {
525 $space_phid = $default_space->getPHID();
530 case PhabricatorTransactions
::TYPE_EDGE
:
531 $edge_type = $xaction->getMetadataValue('edge:type');
535 "Edge transaction has no '%s'!",
539 // See T13082. If this is an inverse edit, the parent editor has
540 // already populated the transaction values correctly.
541 if ($this->getIsInverseEdgeEditor()) {
542 return $xaction->getOldValue();
545 $old_edges = array();
546 if ($object->getPHID()) {
547 $edge_src = $object->getPHID();
549 $old_edges = id(new PhabricatorEdgeQuery())
550 ->withSourcePHIDs(array($edge_src))
551 ->withEdgeTypes(array($edge_type))
555 $old_edges = $old_edges[$edge_src][$edge_type];
558 case PhabricatorTransactions
::TYPE_CUSTOMFIELD
:
559 // NOTE: Custom fields have their old value pre-populated when they are
560 // built by PhabricatorCustomFieldList.
561 return $xaction->getOldValue();
562 case PhabricatorTransactions
::TYPE_COMMENT
:
564 case PhabricatorTransactions
::TYPE_FILE
:
567 return $this->getCustomTransactionOldValue($object, $xaction);
571 private function getTransactionNewValue(
572 PhabricatorLiskDAO
$object,
573 PhabricatorApplicationTransaction
$xaction) {
575 $type = $xaction->getTransactionType();
577 $xtype = $this->getModularTransactionType($type);
579 $xtype = clone $xtype;
580 $xtype->setStorage($xaction);
581 return $xtype->generateNewValue($object, $xaction->getNewValue());
585 case PhabricatorTransactions
::TYPE_CREATE
:
587 case PhabricatorTransactions
::TYPE_SUBSCRIBERS
:
588 return $this->getPHIDTransactionNewValue($xaction);
589 case PhabricatorTransactions
::TYPE_VIEW_POLICY
:
590 case PhabricatorTransactions
::TYPE_EDIT_POLICY
:
591 case PhabricatorTransactions
::TYPE_JOIN_POLICY
:
592 case PhabricatorTransactions
::TYPE_INTERACT_POLICY
:
593 case PhabricatorTransactions
::TYPE_TOKEN
:
594 case PhabricatorTransactions
::TYPE_INLINESTATE
:
595 case PhabricatorTransactions
::TYPE_SUBTYPE
:
596 case PhabricatorTransactions
::TYPE_HISTORY
:
597 case PhabricatorTransactions
::TYPE_FILE
:
598 return $xaction->getNewValue();
599 case PhabricatorTransactions
::TYPE_MFA
:
601 case PhabricatorTransactions
::TYPE_SPACE
:
602 $space_phid = $xaction->getNewValue();
603 if (!strlen($space_phid)) {
604 // If an install has no Spaces or the Spaces controls are not visible
605 // to the viewer, we might end up with the empty string here instead
606 // of a strict `null`, because some controller just used `getStr()`
607 // to read the space PHID from the request.
608 // Just make this work like callers might reasonably expect so we
609 // don't need to handle this specially in every EditController.
610 return $this->getActor()->getDefaultSpacePHID();
614 case PhabricatorTransactions
::TYPE_EDGE
:
615 // See T13082. If this is an inverse edit, the parent editor has
616 // already populated appropriate transaction values.
617 if ($this->getIsInverseEdgeEditor()) {
618 return $xaction->getNewValue();
621 $new_value = $this->getEdgeTransactionNewValue($xaction);
623 $edge_type = $xaction->getMetadataValue('edge:type');
624 $type_project = PhabricatorProjectObjectHasProjectEdgeType
::EDGECONST
;
625 if ($edge_type == $type_project) {
626 $new_value = $this->applyProjectConflictRules($new_value);
630 case PhabricatorTransactions
::TYPE_CUSTOMFIELD
:
631 $field = $this->getCustomFieldForTransaction($object, $xaction);
632 return $field->getNewValueFromApplicationTransactions($xaction);
633 case PhabricatorTransactions
::TYPE_COMMENT
:
636 return $this->getCustomTransactionNewValue($object, $xaction);
640 protected function getCustomTransactionOldValue(
641 PhabricatorLiskDAO
$object,
642 PhabricatorApplicationTransaction
$xaction) {
643 throw new Exception(pht('Capability not supported!'));
646 protected function getCustomTransactionNewValue(
647 PhabricatorLiskDAO
$object,
648 PhabricatorApplicationTransaction
$xaction) {
649 throw new Exception(pht('Capability not supported!'));
652 protected function transactionHasEffect(
653 PhabricatorLiskDAO
$object,
654 PhabricatorApplicationTransaction
$xaction) {
656 switch ($xaction->getTransactionType()) {
657 case PhabricatorTransactions
::TYPE_CREATE
:
658 case PhabricatorTransactions
::TYPE_HISTORY
:
660 case PhabricatorTransactions
::TYPE_CUSTOMFIELD
:
661 $field = $this->getCustomFieldForTransaction($object, $xaction);
662 return $field->getApplicationTransactionHasEffect($xaction);
663 case PhabricatorTransactions
::TYPE_EDGE
:
664 // A straight value comparison here doesn't always get the right
665 // result, because newly added edges aren't fully populated. Instead,
666 // compare the changes in a more granular way.
667 $old = $xaction->getOldValue();
668 $new = $xaction->getNewValue();
670 $old_dst = array_keys($old);
671 $new_dst = array_keys($new);
673 // NOTE: For now, we don't consider edge reordering to be a change.
674 // We have very few order-dependent edges and effectively no order
675 // oriented UI. This might change in the future.
679 if ($old_dst !== $new_dst) {
680 // We've added or removed edges, so this transaction definitely
685 // We haven't added or removed edges, but we might have changed
687 foreach ($old as $key => $old_value) {
688 $new_value = $new[$key];
689 if ($old_value['data'] !== $new_value['data']) {
697 $type = $xaction->getTransactionType();
698 $xtype = $this->getModularTransactionType($type);
700 return $xtype->getTransactionHasEffect(
702 $xaction->getOldValue(),
703 $xaction->getNewValue());
706 if ($xaction->hasComment()) {
710 return ($xaction->getOldValue() !== $xaction->getNewValue());
713 protected function shouldApplyInitialEffects(
714 PhabricatorLiskDAO
$object,
719 protected function applyInitialEffects(
720 PhabricatorLiskDAO
$object,
722 throw new PhutilMethodNotImplementedException();
725 private function applyInternalEffects(
726 PhabricatorLiskDAO
$object,
727 PhabricatorApplicationTransaction
$xaction) {
729 $type = $xaction->getTransactionType();
731 $xtype = $this->getModularTransactionType($type);
733 $xtype = clone $xtype;
734 $xtype->setStorage($xaction);
735 return $xtype->applyInternalEffects($object, $xaction->getNewValue());
739 case PhabricatorTransactions
::TYPE_CUSTOMFIELD
:
740 $field = $this->getCustomFieldForTransaction($object, $xaction);
741 return $field->applyApplicationTransactionInternalEffects($xaction);
742 case PhabricatorTransactions
::TYPE_CREATE
:
743 case PhabricatorTransactions
::TYPE_HISTORY
:
744 case PhabricatorTransactions
::TYPE_SUBTYPE
:
745 case PhabricatorTransactions
::TYPE_MFA
:
746 case PhabricatorTransactions
::TYPE_TOKEN
:
747 case PhabricatorTransactions
::TYPE_VIEW_POLICY
:
748 case PhabricatorTransactions
::TYPE_EDIT_POLICY
:
749 case PhabricatorTransactions
::TYPE_JOIN_POLICY
:
750 case PhabricatorTransactions
::TYPE_INTERACT_POLICY
:
751 case PhabricatorTransactions
::TYPE_SUBSCRIBERS
:
752 case PhabricatorTransactions
::TYPE_INLINESTATE
:
753 case PhabricatorTransactions
::TYPE_EDGE
:
754 case PhabricatorTransactions
::TYPE_SPACE
:
755 case PhabricatorTransactions
::TYPE_COMMENT
:
756 case PhabricatorTransactions
::TYPE_FILE
:
757 return $this->applyBuiltinInternalTransaction($object, $xaction);
760 return $this->applyCustomInternalTransaction($object, $xaction);
763 private function applyExternalEffects(
764 PhabricatorLiskDAO
$object,
765 PhabricatorApplicationTransaction
$xaction) {
767 $type = $xaction->getTransactionType();
769 $xtype = $this->getModularTransactionType($type);
771 $xtype = clone $xtype;
772 $xtype->setStorage($xaction);
773 return $xtype->applyExternalEffects($object, $xaction->getNewValue());
777 case PhabricatorTransactions
::TYPE_SUBSCRIBERS
:
778 $subeditor = id(new PhabricatorSubscriptionsEditor())
780 ->setActor($this->requireActor());
782 $old_map = array_fuse($xaction->getOldValue());
783 $new_map = array_fuse($xaction->getNewValue());
785 $subeditor->unsubscribe(
787 array_diff_key($old_map, $new_map)));
789 $subeditor->subscribeExplicit(
791 array_diff_key($new_map, $old_map)));
795 // for the rest of these edits, subscribers should include those just
796 // added as well as those just removed.
797 $subscribers = array_unique(array_merge(
799 $xaction->getOldValue(),
800 $xaction->getNewValue()));
801 $this->subscribers
= $subscribers;
802 return $this->applyBuiltinExternalTransaction($object, $xaction);
804 case PhabricatorTransactions
::TYPE_CUSTOMFIELD
:
805 $field = $this->getCustomFieldForTransaction($object, $xaction);
806 return $field->applyApplicationTransactionExternalEffects($xaction);
807 case PhabricatorTransactions
::TYPE_CREATE
:
808 case PhabricatorTransactions
::TYPE_HISTORY
:
809 case PhabricatorTransactions
::TYPE_SUBTYPE
:
810 case PhabricatorTransactions
::TYPE_MFA
:
811 case PhabricatorTransactions
::TYPE_EDGE
:
812 case PhabricatorTransactions
::TYPE_TOKEN
:
813 case PhabricatorTransactions
::TYPE_VIEW_POLICY
:
814 case PhabricatorTransactions
::TYPE_EDIT_POLICY
:
815 case PhabricatorTransactions
::TYPE_JOIN_POLICY
:
816 case PhabricatorTransactions
::TYPE_INTERACT_POLICY
:
817 case PhabricatorTransactions
::TYPE_INLINESTATE
:
818 case PhabricatorTransactions
::TYPE_SPACE
:
819 case PhabricatorTransactions
::TYPE_COMMENT
:
820 case PhabricatorTransactions
::TYPE_FILE
:
821 return $this->applyBuiltinExternalTransaction($object, $xaction);
824 return $this->applyCustomExternalTransaction($object, $xaction);
827 protected function applyCustomInternalTransaction(
828 PhabricatorLiskDAO
$object,
829 PhabricatorApplicationTransaction
$xaction) {
830 $type = $xaction->getTransactionType();
833 "Transaction type '%s' is missing an internal apply implementation!",
837 protected function applyCustomExternalTransaction(
838 PhabricatorLiskDAO
$object,
839 PhabricatorApplicationTransaction
$xaction) {
840 $type = $xaction->getTransactionType();
843 "Transaction type '%s' is missing an external apply implementation!",
848 * @{class:PhabricatorTransactions} provides many built-in transactions
849 * which should not require much - if any - code in specific applications.
851 * This method is a hook for the exceedingly-rare cases where you may need
852 * to do **additional** work for built-in transactions. Developers should
853 * extend this method, making sure to return the parent implementation
854 * regardless of handling any transactions.
856 * See also @{method:applyBuiltinExternalTransaction}.
858 protected function applyBuiltinInternalTransaction(
859 PhabricatorLiskDAO
$object,
860 PhabricatorApplicationTransaction
$xaction) {
862 switch ($xaction->getTransactionType()) {
863 case PhabricatorTransactions
::TYPE_VIEW_POLICY
:
864 $object->setViewPolicy($xaction->getNewValue());
866 case PhabricatorTransactions
::TYPE_EDIT_POLICY
:
867 $object->setEditPolicy($xaction->getNewValue());
869 case PhabricatorTransactions
::TYPE_JOIN_POLICY
:
870 $object->setJoinPolicy($xaction->getNewValue());
872 case PhabricatorTransactions
::TYPE_INTERACT_POLICY
:
873 $object->setInteractPolicy($xaction->getNewValue());
875 case PhabricatorTransactions
::TYPE_SPACE
:
876 $object->setSpacePHID($xaction->getNewValue());
878 case PhabricatorTransactions
::TYPE_SUBTYPE
:
879 $object->setEditEngineSubtype($xaction->getNewValue());
885 * See @{method::applyBuiltinInternalTransaction}.
887 protected function applyBuiltinExternalTransaction(
888 PhabricatorLiskDAO
$object,
889 PhabricatorApplicationTransaction
$xaction) {
891 switch ($xaction->getTransactionType()) {
892 case PhabricatorTransactions
::TYPE_EDGE
:
893 if ($this->getIsInverseEdgeEditor()) {
894 // If we're writing an inverse edge transaction, don't actually
895 // do anything. The initiating editor on the other side of the
896 // transaction will take care of the edge writes.
900 $old = $xaction->getOldValue();
901 $new = $xaction->getNewValue();
902 $src = $object->getPHID();
903 $const = $xaction->getMetadataValue('edge:type');
905 foreach ($new as $dst_phid => $edge) {
906 $new[$dst_phid]['src'] = $src;
909 $editor = new PhabricatorEdgeEditor();
911 foreach ($old as $dst_phid => $edge) {
912 if (!empty($new[$dst_phid])) {
913 if ($old[$dst_phid]['data'] === $new[$dst_phid]['data']) {
917 $editor->removeEdge($src, $const, $dst_phid);
920 foreach ($new as $dst_phid => $edge) {
921 if (!empty($old[$dst_phid])) {
922 if ($old[$dst_phid]['data'] === $new[$dst_phid]['data']) {
928 'data' => $edge['data'],
931 $editor->addEdge($src, $const, $dst_phid, $data);
936 $this->updateWorkboardColumns($object, $const, $old, $new);
938 case PhabricatorTransactions
::TYPE_VIEW_POLICY
:
939 case PhabricatorTransactions
::TYPE_SPACE
:
940 $this->scrambleFileSecrets($object);
942 case PhabricatorTransactions
::TYPE_HISTORY
:
943 $this->sendHistory
= true;
945 case PhabricatorTransactions
::TYPE_FILE
:
946 $this->applyFileTransaction($object, $xaction);
951 private function applyFileTransaction(
952 PhabricatorLiskDAO
$object,
953 PhabricatorApplicationTransaction
$xaction) {
955 $old_map = $xaction->getOldValue();
956 $new_map = $xaction->getNewValue();
958 $add_phids = array();
959 $rem_phids = array();
961 foreach ($new_map as $phid => $mode) {
962 $add_phids[$phid] = $mode;
965 foreach ($old_map as $phid => $mode) {
966 if (!isset($new_map[$phid])) {
967 $rem_phids[] = $phid;
971 $now = PhabricatorTime
::getNow();
972 $object_phid = $object->getPHID();
973 $attacher_phid = $this->getActingAsPHID();
975 $attachment_table = new PhabricatorFileAttachment();
976 $attachment_conn = $attachment_table->establishConnection('w');
979 foreach ($add_phids as $add_phid => $add_mode) {
980 $add_sql[] = qsprintf(
982 '(%s, %s, %s, %ns, %d, %d)',
992 foreach ($rem_phids as $rem_phid) {
993 $rem_sql[] = qsprintf(
999 foreach (PhabricatorLiskDAO
::chunkSQL($add_sql) as $chunk) {
1002 'INSERT INTO %R (objectPHID, filePHID, attachmentMode,
1003 attacherPHID, dateCreated, dateModified)
1005 ON DUPLICATE KEY UPDATE
1006 attachmentMode = VALUES(attachmentMode),
1007 attacherPHID = VALUES(attacherPHID),
1008 dateModified = VALUES(dateModified)',
1013 foreach (PhabricatorLiskDAO
::chunkSQL($rem_sql) as $chunk) {
1016 'DELETE FROM %R WHERE objectPHID = %s AND filePHID in (%LQ)',
1024 * Fill in a transaction's common values, like author and content source.
1026 protected function populateTransaction(
1027 PhabricatorLiskDAO
$object,
1028 PhabricatorApplicationTransaction
$xaction) {
1030 $actor = $this->getActor();
1032 // TODO: This needs to be more sophisticated once we have meta-policies.
1033 $xaction->setViewPolicy(PhabricatorPolicies
::POLICY_PUBLIC
);
1035 if ($actor->isOmnipotent()) {
1036 $xaction->setEditPolicy(PhabricatorPolicies
::POLICY_NOONE
);
1038 $xaction->setEditPolicy($this->getActingAsPHID());
1041 // If the transaction already has an explicit author PHID, allow it to
1042 // stand. This is used by applications like Owners that hook into the
1043 // post-apply change pipeline.
1044 if (!$xaction->getAuthorPHID()) {
1045 $xaction->setAuthorPHID($this->getActingAsPHID());
1048 $xaction->setContentSource($this->getContentSource());
1049 $xaction->attachViewer($actor);
1050 $xaction->attachObject($object);
1052 if ($object->getPHID()) {
1053 $xaction->setObjectPHID($object->getPHID());
1056 if ($this->getIsSilent()) {
1057 $xaction->setIsSilentTransaction(true);
1063 protected function didApplyInternalEffects(
1064 PhabricatorLiskDAO
$object,
1069 protected function applyFinalEffects(
1070 PhabricatorLiskDAO
$object,
1075 final protected function didCommitTransactions(
1076 PhabricatorLiskDAO
$object,
1079 foreach ($xactions as $xaction) {
1080 $type = $xaction->getTransactionType();
1082 // See T13082. When we're writing edges that imply corresponding inverse
1083 // transactions, apply those inverse transactions now. We have to wait
1084 // until the object we're editing (with this editor) has committed its
1085 // transactions to do this. If we don't, the inverse editor may race,
1086 // build a mail before we actually commit this object, and render "alice
1087 // added an edge: Unknown Object".
1089 if ($type === PhabricatorTransactions
::TYPE_EDGE
) {
1090 // Don't do anything if we're already an inverse edge editor.
1091 if ($this->getIsInverseEdgeEditor()) {
1095 $edge_const = $xaction->getMetadataValue('edge:type');
1096 $edge_type = PhabricatorEdgeType
::getByConstant($edge_const);
1097 if ($edge_type->shouldWriteInverseTransactions()) {
1098 $this->applyInverseEdgeTransactions(
1101 $edge_type->getInverseEdgeConstant());
1106 $xtype = $this->getModularTransactionType($type);
1111 $xtype = clone $xtype;
1112 $xtype->setStorage($xaction);
1113 $xtype->didCommitTransaction($object, $xaction->getNewValue());
1117 public function setContentSource(PhabricatorContentSource
$content_source) {
1118 $this->contentSource
= $content_source;
1122 public function setContentSourceFromRequest(AphrontRequest
$request) {
1123 $this->setRequest($request);
1124 return $this->setContentSource(
1125 PhabricatorContentSource
::newFromRequest($request));
1128 public function getContentSource() {
1129 return $this->contentSource
;
1132 public function setRequest(AphrontRequest
$request) {
1133 $this->request
= $request;
1137 public function getRequest() {
1138 return $this->request
;
1141 public function setCancelURI($cancel_uri) {
1142 $this->cancelURI
= $cancel_uri;
1146 public function getCancelURI() {
1147 return $this->cancelURI
;
1150 protected function getTransactionGroupID() {
1151 if ($this->transactionGroupID
=== null) {
1152 $this->transactionGroupID
= Filesystem
::readRandomCharacters(32);
1155 return $this->transactionGroupID
;
1158 final public function applyTransactions(
1159 PhabricatorLiskDAO
$object,
1162 $is_new = ($object->getID() === null);
1163 $this->isNewObject
= $is_new;
1165 $is_preview = $this->getIsPreview();
1166 $read_locking = false;
1167 $transaction_open = false;
1169 // If we're attempting to apply transactions, lock and reload the object
1170 // before we go anywhere. If we don't do this at the very beginning, we
1171 // may be looking at an older version of the object when we populate and
1172 // filter the transactions. See PHI1165 for an example.
1176 $this->buildOldRecipientLists($object, $xactions);
1178 $object->openTransaction();
1179 $transaction_open = true;
1181 $object->beginReadLocking();
1182 $read_locking = true;
1189 $this->object = $object;
1190 $this->xactions
= $xactions;
1192 $this->validateEditParameters($object, $xactions);
1193 $xactions = $this->newMFATransactions($object, $xactions);
1195 $actor = $this->requireActor();
1197 // NOTE: Some transaction expansion requires that the edited object be
1199 foreach ($xactions as $xaction) {
1200 $xaction->attachObject($object);
1201 $xaction->attachViewer($actor);
1204 $xactions = $this->expandTransactions($object, $xactions);
1205 $xactions = $this->expandSupportTransactions($object, $xactions);
1206 $xactions = $this->combineTransactions($xactions);
1208 foreach ($xactions as $xaction) {
1209 $xaction = $this->populateTransaction($object, $xaction);
1214 $type_map = mgroup($xactions, 'getTransactionType');
1215 foreach ($this->getTransactionTypes() as $type) {
1216 $type_xactions = idx($type_map, $type, array());
1217 $errors[] = $this->validateTransaction(
1223 $errors[] = $this->validateAllTransactions($object, $xactions);
1224 $errors[] = $this->validateTransactionsWithExtensions(
1227 $errors = array_mergev($errors);
1229 $continue_on_missing = $this->getContinueOnMissingFields();
1230 foreach ($errors as $key => $error) {
1231 if ($continue_on_missing && $error->getIsMissingFieldError()) {
1232 unset($errors[$key]);
1237 throw new PhabricatorApplicationTransactionValidationException(
1241 if ($this->raiseWarnings
) {
1242 $warnings = array();
1243 foreach ($xactions as $xaction) {
1244 if ($this->hasWarnings($object, $xaction)) {
1245 $warnings[] = $xaction;
1249 throw new PhabricatorApplicationTransactionWarningException(
1255 foreach ($xactions as $xaction) {
1256 $this->adjustTransactionValues($object, $xaction);
1259 // Now that we've merged and combined transactions, check for required
1260 // capabilities. Note that we're doing this before filtering
1261 // transactions: if you try to apply an edit which you do not have
1262 // permission to apply, we want to give you a permissions error even
1263 // if the edit would have no effect.
1264 $this->applyCapabilityChecks($object, $xactions);
1266 $xactions = $this->filterTransactions($object, $xactions);
1269 $this->hasRequiredMFA
= true;
1270 if ($this->getShouldRequireMFA()) {
1271 $this->requireMFA($object, $xactions);
1274 if ($this->shouldApplyInitialEffects($object, $xactions)) {
1275 if (!$transaction_open) {
1276 $object->openTransaction();
1277 $transaction_open = true;
1282 if ($this->shouldApplyInitialEffects($object, $xactions)) {
1283 $this->applyInitialEffects($object, $xactions);
1286 // TODO: Once everything is on EditEngine, just use getIsNewObject() to
1287 // figure this out instead.
1288 $mark_as_create = false;
1289 $create_type = PhabricatorTransactions
::TYPE_CREATE
;
1290 foreach ($xactions as $xaction) {
1291 if ($xaction->getTransactionType() == $create_type) {
1292 $mark_as_create = true;
1296 if ($mark_as_create) {
1297 foreach ($xactions as $xaction) {
1298 $xaction->setIsCreateTransaction(true);
1302 $xactions = $this->sortTransactions($xactions);
1305 $this->loadHandles($xactions);
1309 $comment_editor = id(new PhabricatorApplicationTransactionCommentEditor())
1311 ->setActingAsPHID($this->getActingAsPHID())
1312 ->setContentSource($this->getContentSource())
1313 ->setIsNewComment(true);
1315 if (!$transaction_open) {
1316 $object->openTransaction();
1317 $transaction_open = true;
1320 // We can technically test any object for CAN_INTERACT, but we can
1321 // run into some issues in doing so (for example, in project unit tests).
1322 // For now, only test for CAN_INTERACT if the object is explicitly a
1325 $was_locked = false;
1326 if ($object instanceof PhabricatorEditEngineLockableInterface
) {
1327 $was_locked = !PhabricatorPolicyFilter
::canInteract($actor, $object);
1330 foreach ($xactions as $xaction) {
1331 $this->applyInternalEffects($object, $xaction);
1334 $xactions = $this->didApplyInternalEffects($object, $xactions);
1338 } catch (AphrontDuplicateKeyQueryException
$ex) {
1339 // This callback has an opportunity to throw a better exception,
1340 // so execution may end here.
1341 $this->didCatchDuplicateKeyException($object, $xactions, $ex);
1346 $group_id = $this->getTransactionGroupID();
1348 foreach ($xactions as $xaction) {
1350 $is_override = $this->isLockOverrideTransaction($xaction);
1352 $xaction->setIsLockOverrideTransaction(true);
1356 $xaction->setObjectPHID($object->getPHID());
1357 $xaction->setTransactionGroupID($group_id);
1359 if ($xaction->getComment()) {
1360 $xaction->setPHID($xaction->generatePHID());
1361 $comment_editor->applyEdit($xaction, $xaction->getComment());
1364 // TODO: This is a transitional hack to let us migrate edge
1365 // transactions to a more efficient storage format. For now, we're
1366 // going to write a new slim format to the database but keep the old
1367 // bulky format on the objects so we don't have to upgrade all the
1368 // edit logic to the new format yet. See T13051.
1370 $edge_type = PhabricatorTransactions
::TYPE_EDGE
;
1371 if ($xaction->getTransactionType() == $edge_type) {
1372 $bulky_old = $xaction->getOldValue();
1373 $bulky_new = $xaction->getNewValue();
1375 $record = PhabricatorEdgeChangeRecord
::newFromTransaction($xaction);
1376 $slim_old = $record->getModernOldEdgeTransactionData();
1377 $slim_new = $record->getModernNewEdgeTransactionData();
1379 $xaction->setOldValue($slim_old);
1380 $xaction->setNewValue($slim_new);
1383 $xaction->setOldValue($bulky_old);
1384 $xaction->setNewValue($bulky_new);
1391 foreach ($xactions as $xaction) {
1392 $this->applyExternalEffects($object, $xaction);
1395 $xactions = $this->applyFinalEffects($object, $xactions);
1397 if ($read_locking) {
1398 $object->endReadLocking();
1399 $read_locking = false;
1402 if ($transaction_open) {
1403 $object->saveTransaction();
1404 $transaction_open = false;
1407 $this->didCommitTransactions($object, $xactions);
1409 } catch (Exception
$ex) {
1410 if ($read_locking) {
1411 $object->endReadLocking();
1412 $read_locking = false;
1415 if ($transaction_open) {
1416 $object->killTransaction();
1417 $transaction_open = false;
1423 // If we need to perform cache engine updates, execute them now.
1424 id(new PhabricatorCacheEngine())
1425 ->updateObject($object);
1427 // Now that we've completely applied the core transaction set, try to apply
1428 // Herald rules. Herald rules are allowed to either take direct actions on
1429 // the database (like writing flags), or take indirect actions (like saving
1430 // some targets for CC when we generate mail a little later), or return
1431 // transactions which we'll apply normally using another Editor.
1433 // First, check if *this* is a sub-editor which is itself applying Herald
1434 // rules: if it is, stop working and return so we don't descend into
1437 // Otherwise, we're not a Herald editor, so process Herald rules (possibly
1438 // using a Herald editor to apply resulting transactions) and then send out
1439 // mail, notifications, and feed updates about everything.
1441 if ($this->getIsHeraldEditor()) {
1442 // We are the Herald editor, so stop work here and return the updated
1445 } else if ($this->getIsInverseEdgeEditor()) {
1446 // Do not run Herald if we're just recording that this object was
1447 // mentioned elsewhere. This tends to create Herald side effects which
1448 // feel arbitrary, and can really slow down edits which mention a large
1449 // number of other objects. See T13114.
1450 } else if ($this->shouldApplyHeraldRules($object, $xactions)) {
1451 // We are not the Herald editor, so try to apply Herald rules.
1452 $herald_xactions = $this->applyHeraldRules($object, $xactions);
1454 if ($herald_xactions) {
1455 $xscript_id = $this->getHeraldTranscript()->getID();
1456 foreach ($herald_xactions as $herald_xaction) {
1457 // Don't set a transcript ID if this is a transaction from another
1458 // application or source, like Owners.
1459 if ($herald_xaction->getAuthorPHID()) {
1463 $herald_xaction->setMetadataValue('herald:transcriptID', $xscript_id);
1466 // NOTE: We're acting as the omnipotent user because rules deal with
1467 // their own policy issues. We use a synthetic author PHID (the
1468 // Herald application) as the author of record, so that transactions
1469 // will render in a reasonable way ("Herald assigned this task ...").
1470 $herald_actor = PhabricatorUser
::getOmnipotentUser();
1471 $herald_phid = id(new PhabricatorHeraldApplication())->getPHID();
1473 // TODO: It would be nice to give transactions a more specific source
1474 // which points at the rule which generated them. You can figure this
1475 // out from transcripts, but it would be cleaner if you didn't have to.
1477 $herald_source = PhabricatorContentSource
::newForSource(
1478 PhabricatorHeraldContentSource
::SOURCECONST
);
1480 $herald_editor = $this->newEditorCopy()
1481 ->setContinueOnNoEffect(true)
1482 ->setContinueOnMissingFields(true)
1483 ->setIsHeraldEditor(true)
1484 ->setActor($herald_actor)
1485 ->setActingAsPHID($herald_phid)
1486 ->setContentSource($herald_source);
1488 $herald_xactions = $herald_editor->applyTransactions(
1492 // Merge the new transactions into the transaction list: we want to
1493 // send email and publish feed stories about them, too.
1494 $xactions = array_merge($xactions, $herald_xactions);
1497 // If Herald did not generate transactions, we may still need to handle
1498 // "Send an Email" rules.
1499 $adapter = $this->getHeraldAdapter();
1500 $this->heraldEmailPHIDs
= $adapter->getEmailPHIDs();
1501 $this->heraldForcedEmailPHIDs
= $adapter->getForcedEmailPHIDs();
1502 $this->webhookMap
= $adapter->getWebhookMap();
1505 $xactions = $this->didApplyTransactions($object, $xactions);
1507 if ($object instanceof PhabricatorCustomFieldInterface
) {
1508 // Maybe this makes more sense to move into the search index itself? For
1509 // now I'm putting it here since I think we might end up with things that
1510 // need it to be up to date once the next page loads, but if we don't go
1511 // there we could move it into search once search moves to the daemons.
1513 // It now happens in the search indexer as well, but the search indexer is
1514 // always daemonized, so the logic above still potentially holds. We could
1515 // possibly get rid of this. The major motivation for putting it in the
1516 // indexer was to enable reindexing to work.
1518 $fields = PhabricatorCustomField
::getObjectFields(
1520 PhabricatorCustomField
::ROLE_APPLICATIONSEARCH
);
1521 $fields->readFieldsFromStorage($object);
1522 $fields->rebuildIndexes($object);
1525 $herald_xscript = $this->getHeraldTranscript();
1526 if ($herald_xscript) {
1527 $herald_header = $herald_xscript->getXHeraldRulesHeader();
1528 $herald_header = HeraldTranscript
::saveXHeraldRulesHeader(
1532 $herald_header = HeraldTranscript
::loadXHeraldRulesHeader(
1533 $object->getPHID());
1535 $this->heraldHeader
= $herald_header;
1537 // See PHI1134. If we're a subeditor, we don't publish information about
1538 // the edit yet. Our parent editor still needs to finish applying
1539 // transactions and execute Herald, which may change the information we
1542 // For example, Herald actions may change the parent object's title or
1543 // visibility, or Herald may apply rules like "Must Encrypt" that affect
1546 // Once the parent finishes work, it will queue its own publish step and
1547 // then queue publish steps for its children.
1549 $this->publishableObject
= $object;
1550 $this->publishableTransactions
= $xactions;
1551 if (!$this->parentEditor
) {
1552 $this->queuePublishing();
1558 private function queuePublishing() {
1559 $object = $this->publishableObject
;
1560 $xactions = $this->publishableTransactions
;
1563 throw new Exception(
1565 'Editor method "queuePublishing()" was called, but no publishable '.
1566 'object is present. This Editor is not ready to publish.'));
1569 // We're going to compute some of the data we'll use to publish these
1570 // transactions here, before queueing a worker.
1572 // Primarily, this is more correct: we want to publish the object as it
1573 // exists right now. The worker may not execute for some time, and we want
1574 // to use the current To/CC list, not respect any changes which may occur
1575 // between now and when the worker executes.
1577 // As a secondary benefit, this tends to reduce the amount of state that
1578 // Editors need to pass into workers.
1579 $object = $this->willPublish($object, $xactions);
1581 if (!$this->getIsSilent()) {
1582 if ($this->shouldSendMail($object, $xactions)) {
1583 $this->mailShouldSend
= true;
1584 $this->mailToPHIDs
= $this->getMailTo($object);
1585 $this->mailCCPHIDs
= $this->getMailCC($object);
1586 $this->mailUnexpandablePHIDs
= $this->newMailUnexpandablePHIDs($object);
1588 // Add any recipients who were previously on the notification list
1589 // but were removed by this change.
1590 $this->applyOldRecipientLists();
1592 if ($object instanceof PhabricatorSubscribableInterface
) {
1593 $this->mailMutedPHIDs
= PhabricatorEdgeQuery
::loadDestinationPHIDs(
1595 PhabricatorMutedByEdgeType
::EDGECONST
);
1597 $this->mailMutedPHIDs
= array();
1600 $mail_xactions = $this->getTransactionsForMail($object, $xactions);
1601 $stamps = $this->newMailStamps($object, $xactions);
1602 foreach ($stamps as $stamp) {
1603 $this->mailStamps
[] = $stamp->toDictionary();
1607 if ($this->shouldPublishFeedStory($object, $xactions)) {
1608 $this->feedShouldPublish
= true;
1609 $this->feedRelatedPHIDs
= $this->getFeedRelatedPHIDs(
1612 $this->feedNotifyPHIDs
= $this->getFeedNotifyPHIDs(
1618 PhabricatorWorker
::scheduleTask(
1619 'PhabricatorApplicationTransactionPublishWorker',
1621 'objectPHID' => $object->getPHID(),
1622 'actorPHID' => $this->getActingAsPHID(),
1623 'xactionPHIDs' => mpull($xactions, 'getPHID'),
1624 'state' => $this->getWorkerState(),
1627 'objectPHID' => $object->getPHID(),
1628 'priority' => PhabricatorWorker
::PRIORITY_ALERTS
,
1631 foreach ($this->subEditors
as $sub_editor) {
1632 $sub_editor->queuePublishing();
1635 $this->flushTransactionQueue($object);
1638 protected function didCatchDuplicateKeyException(
1639 PhabricatorLiskDAO
$object,
1645 public function publishTransactions(
1646 PhabricatorLiskDAO
$object,
1649 $this->object = $object;
1650 $this->xactions
= $xactions;
1652 // Hook for edges or other properties that may need (re-)loading
1653 $object = $this->willPublish($object, $xactions);
1655 // The object might have changed, so reassign it.
1656 $this->object = $object;
1658 $messages = array();
1659 if ($this->mailShouldSend
) {
1660 $messages = $this->buildMail($object, $xactions);
1663 if ($this->supportsSearch()) {
1664 PhabricatorSearchWorker
::queueDocumentForIndexing(
1667 'transactionPHIDs' => mpull($xactions, 'getPHID'),
1671 if ($this->feedShouldPublish
) {
1673 foreach ($messages as $mail) {
1674 foreach ($mail->buildRecipientList() as $phid) {
1675 $mailed[$phid] = $phid;
1679 $this->publishFeedStory($object, $xactions, $mailed);
1682 if ($this->sendHistory
) {
1683 $history_mail = $this->buildHistoryMail($object);
1684 if ($history_mail) {
1685 $messages[] = $history_mail;
1689 foreach ($this->newAuxiliaryMail($object, $xactions) as $message) {
1690 $messages[] = $message;
1693 // NOTE: This actually sends the mail. We do this last to reduce the chance
1694 // that we send some mail, hit an exception, then send the mail again when
1696 foreach ($messages as $mail) {
1700 $this->queueWebhooks($object, $xactions);
1705 protected function didApplyTransactions($object, array $xactions) {
1706 // Hook for subclasses.
1710 private function loadHandles(array $xactions) {
1712 foreach ($xactions as $key => $xaction) {
1713 $phids[$key] = $xaction->getRequiredHandlePHIDs();
1716 $merged = array_mergev($phids);
1718 $handles = id(new PhabricatorHandleQuery())
1719 ->setViewer($this->requireActor())
1720 ->withPHIDs($merged)
1723 foreach ($xactions as $key => $xaction) {
1724 $xaction->setHandles(array_select_keys($handles, $phids[$key]));
1728 private function loadSubscribers(PhabricatorLiskDAO
$object) {
1729 if ($object->getPHID() &&
1730 ($object instanceof PhabricatorSubscribableInterface
)) {
1731 $subs = PhabricatorSubscribersQuery
::loadSubscribersForPHID(
1732 $object->getPHID());
1733 $this->subscribers
= array_fuse($subs);
1735 $this->subscribers
= array();
1739 private function validateEditParameters(
1740 PhabricatorLiskDAO
$object,
1743 if (!$this->getContentSource()) {
1744 throw new PhutilInvalidStateException('setContentSource');
1747 // Do a bunch of sanity checks that the incoming transactions are fresh.
1748 // They should be unsaved and have only "transactionType" and "newValue"
1751 $types = array_fill_keys($this->getTransactionTypes(), true);
1753 assert_instances_of($xactions, 'PhabricatorApplicationTransaction');
1754 foreach ($xactions as $xaction) {
1755 if ($xaction->getPHID() ||
$xaction->getID()) {
1756 throw new PhabricatorApplicationTransactionStructureException(
1758 pht('You can not apply transactions which already have IDs/PHIDs!'));
1761 if ($xaction->getObjectPHID()) {
1762 throw new PhabricatorApplicationTransactionStructureException(
1765 'You can not apply transactions which already have %s!',
1769 if ($xaction->getCommentPHID()) {
1770 throw new PhabricatorApplicationTransactionStructureException(
1773 'You can not apply transactions which already have %s!',
1777 if ($xaction->getCommentVersion() !== 0) {
1778 throw new PhabricatorApplicationTransactionStructureException(
1781 'You can not apply transactions which already have '.
1782 'commentVersions!'));
1785 $expect_value = !$xaction->shouldGenerateOldValue();
1786 $has_value = $xaction->hasOldValue();
1788 // See T13082. In the narrow case of applying inverse edge edits, we
1789 // expect the old value to be populated.
1790 if ($this->getIsInverseEdgeEditor()) {
1791 $expect_value = true;
1794 if ($expect_value && !$has_value) {
1795 throw new PhabricatorApplicationTransactionStructureException(
1798 'This transaction is supposed to have an %s set, but it does not!',
1802 if ($has_value && !$expect_value) {
1803 throw new PhabricatorApplicationTransactionStructureException(
1806 'This transaction should generate its %s automatically, '.
1807 'but has already had one set!',
1811 $type = $xaction->getTransactionType();
1812 if (empty($types[$type])) {
1813 throw new PhabricatorApplicationTransactionStructureException(
1816 'Transaction has type "%s", but that transaction type is not '.
1817 'supported by this editor (%s).',
1824 private function applyCapabilityChecks(
1825 PhabricatorLiskDAO
$object,
1827 assert_instances_of($xactions, 'PhabricatorApplicationTransaction');
1829 $can_edit = PhabricatorPolicyCapability
::CAN_EDIT
;
1831 if ($this->getIsNewObject()) {
1832 // If we're creating a new object, we don't need any special capabilities
1833 // on the object. The actor has already made it through creation checks,
1834 // and objects which haven't been created yet often can not be
1835 // meaningfully tested for capabilities anyway.
1836 $required_capabilities = array();
1838 if (!$xactions && !$this->xactions
) {
1839 // If we aren't doing anything, require CAN_EDIT to improve consistency.
1840 $required_capabilities = array($can_edit);
1842 $required_capabilities = array();
1844 foreach ($xactions as $xaction) {
1845 $type = $xaction->getTransactionType();
1847 $xtype = $this->getModularTransactionType($type);
1849 $capabilities = $this->getLegacyRequiredCapabilities($xaction);
1851 $capabilities = $xtype->getRequiredCapabilities($object, $xaction);
1854 // For convenience, we allow flexibility in the return types because
1855 // it's very unusual that a transaction actually requires multiple
1856 // capability checks.
1857 if ($capabilities === null) {
1858 $capabilities = array();
1860 $capabilities = (array)$capabilities;
1863 foreach ($capabilities as $capability) {
1864 $required_capabilities[$capability] = $capability;
1870 $required_capabilities = array_fuse($required_capabilities);
1871 $actor = $this->getActor();
1873 if ($required_capabilities) {
1874 id(new PhabricatorPolicyFilter())
1876 ->requireCapabilities($required_capabilities)
1877 ->raisePolicyExceptions(true)
1878 ->apply(array($object));
1882 private function getLegacyRequiredCapabilities(
1883 PhabricatorApplicationTransaction
$xaction) {
1885 $type = $xaction->getTransactionType();
1887 case PhabricatorTransactions
::TYPE_COMMENT
:
1888 // TODO: Comments technically require CAN_INTERACT, but this is
1889 // currently somewhat special and handled through EditEngine. For now,
1890 // don't enforce it here.
1892 case PhabricatorTransactions
::TYPE_SUBSCRIBERS
:
1893 // Anyone can subscribe to or unsubscribe from anything they can view,
1894 // with no other permissions.
1896 $old = array_fuse($xaction->getOldValue());
1897 $new = array_fuse($xaction->getNewValue());
1899 // To remove users other than yourself, you must be able to edit the
1901 $rem = array_diff_key($old, $new);
1902 foreach ($rem as $phid) {
1903 if ($phid !== $this->getActingAsPHID()) {
1904 return PhabricatorPolicyCapability
::CAN_EDIT
;
1908 // To add users other than yourself, you must be able to interact.
1909 // This allows "@mentioning" users to work as long as you can comment
1912 // If you can edit, we return that policy instead so that you can
1913 // override a soft lock and still make edits.
1915 // TODO: This is a little bit hacky. We really want to be able to say
1916 // "this requires either interact or edit", but there's currently no
1917 // way to specify this kind of requirement.
1919 $can_edit = PhabricatorPolicyFilter
::hasCapability(
1922 PhabricatorPolicyCapability
::CAN_EDIT
);
1924 $add = array_diff_key($new, $old);
1925 foreach ($add as $phid) {
1926 if ($phid !== $this->getActingAsPHID()) {
1928 return PhabricatorPolicyCapability
::CAN_EDIT
;
1930 return PhabricatorPolicyCapability
::CAN_INTERACT
;
1936 case PhabricatorTransactions
::TYPE_TOKEN
:
1937 // TODO: This technically requires CAN_INTERACT, like comments.
1939 case PhabricatorTransactions
::TYPE_HISTORY
:
1940 // This is a special magic transaction which sends you history via
1941 // email and is only partially supported in the upstream. You don't
1942 // need any capabilities to apply it.
1944 case PhabricatorTransactions
::TYPE_MFA
:
1945 // Signing a transaction group with MFA does not require permissions
1948 case PhabricatorTransactions
::TYPE_FILE
:
1950 case PhabricatorTransactions
::TYPE_EDGE
:
1951 return $this->getLegacyRequiredEdgeCapabilities($xaction);
1953 // For other older (non-modular) transactions, always require exactly
1954 // CAN_EDIT. Transactions which do not need CAN_EDIT or need additional
1955 // capabilities must move to ModularTransactions.
1956 return PhabricatorPolicyCapability
::CAN_EDIT
;
1960 private function getLegacyRequiredEdgeCapabilities(
1961 PhabricatorApplicationTransaction
$xaction) {
1963 // You don't need to have edit permission on an object to mention it or
1964 // otherwise add a relationship pointing toward it.
1965 if ($this->getIsInverseEdgeEditor()) {
1969 $edge_type = $xaction->getMetadataValue('edge:type');
1970 switch ($edge_type) {
1971 case PhabricatorMutedByEdgeType
::EDGECONST
:
1972 // At time of writing, you can only write this edge for yourself, so
1973 // you don't need permissions. If you can eventually mute an object
1974 // for other users, this would need to be revisited.
1976 case PhabricatorProjectSilencedEdgeType
::EDGECONST
:
1977 // At time of writing, you can only write this edge for yourself, so
1978 // you don't need permissions. If you can eventually silence project
1979 // for other users, this would need to be revisited.
1981 case PhabricatorObjectMentionsObjectEdgeType
::EDGECONST
:
1983 case PhabricatorProjectProjectHasMemberEdgeType
::EDGECONST
:
1984 $old = $xaction->getOldValue();
1985 $new = $xaction->getNewValue();
1987 $add = array_keys(array_diff_key($new, $old));
1988 $rem = array_keys(array_diff_key($old, $new));
1990 $actor_phid = $this->requireActor()->getPHID();
1992 $is_join = (($add === array($actor_phid)) && !$rem);
1993 $is_leave = (($rem === array($actor_phid)) && !$add);
1996 // You need CAN_JOIN to join a project.
1997 return PhabricatorPolicyCapability
::CAN_JOIN
;
2001 $object = $this->object;
2002 // You usually don't need any capabilities to leave a project...
2003 if ($object->getIsMembershipLocked()) {
2004 // ...you must be able to edit to leave locked projects, though.
2005 return PhabricatorPolicyCapability
::CAN_EDIT
;
2011 // You need CAN_EDIT to change members other than yourself.
2012 return PhabricatorPolicyCapability
::CAN_EDIT
;
2013 case PhabricatorObjectHasWatcherEdgeType
::EDGECONST
:
2014 // See PHI1024. Watching a project does not require CAN_EDIT.
2017 return PhabricatorPolicyCapability
::CAN_EDIT
;
2022 private function buildSubscribeTransaction(
2023 PhabricatorLiskDAO
$object,
2027 if (!($object instanceof PhabricatorSubscribableInterface
)) {
2031 if ($this->shouldEnableMentions($object, $xactions)) {
2032 // Identify newly mentioned users. We ignore users who were previously
2033 // mentioned so that we don't re-subscribe users after an edit of text
2034 // which mentions them.
2035 $old_texts = mpull($changes, 'getOldValue');
2036 $new_texts = mpull($changes, 'getNewValue');
2038 $old_phids = PhabricatorMarkupEngine
::extractPHIDsFromMentions(
2042 $new_phids = PhabricatorMarkupEngine
::extractPHIDsFromMentions(
2046 $phids = array_diff($new_phids, $old_phids);
2051 $this->mentionedPHIDs
= $phids;
2053 if ($object->getPHID()) {
2054 // Don't try to subscribe already-subscribed mentions: we want to generate
2055 // a dialog about an action having no effect if the user explicitly adds
2056 // existing CCs, but not if they merely mention existing subscribers.
2057 $phids = array_diff($phids, $this->subscribers
);
2061 $users = id(new PhabricatorPeopleQuery())
2062 ->setViewer($this->getActor())
2065 $users = mpull($users, null, 'getPHID');
2067 foreach ($phids as $key => $phid) {
2068 $user = idx($users, $phid);
2070 // Don't subscribe invalid users.
2072 unset($phids[$key]);
2076 // Don't subscribe bots that get mentioned. If users truly intend
2077 // to subscribe them, they can add them explicitly, but it's generally
2078 // not useful to subscribe bots to objects.
2079 if ($user->getIsSystemAgent()) {
2080 unset($phids[$key]);
2084 // Do not subscribe mentioned users who do not have permission to see
2086 if ($object instanceof PhabricatorPolicyInterface
) {
2087 $can_view = PhabricatorPolicyFilter
::hasCapability(
2090 PhabricatorPolicyCapability
::CAN_VIEW
);
2092 unset($phids[$key]);
2097 // Don't subscribe users who are already automatically subscribed.
2098 if ($object->isAutomaticallySubscribed($phid)) {
2099 unset($phids[$key]);
2104 $phids = array_values($phids);
2111 $xaction = $object->getApplicationTransactionTemplate()
2112 ->setTransactionType(PhabricatorTransactions
::TYPE_SUBSCRIBERS
)
2113 ->setNewValue(array('+' => $phids));
2118 protected function mergeTransactions(
2119 PhabricatorApplicationTransaction
$u,
2120 PhabricatorApplicationTransaction
$v) {
2122 $type = $u->getTransactionType();
2124 $xtype = $this->getModularTransactionType($type);
2126 $object = $this->object;
2127 return $xtype->mergeTransactions($object, $u, $v);
2131 case PhabricatorTransactions
::TYPE_SUBSCRIBERS
:
2132 return $this->mergePHIDOrEdgeTransactions($u, $v);
2133 case PhabricatorTransactions
::TYPE_EDGE
:
2134 $u_type = $u->getMetadataValue('edge:type');
2135 $v_type = $v->getMetadataValue('edge:type');
2136 if ($u_type == $v_type) {
2137 return $this->mergePHIDOrEdgeTransactions($u, $v);
2142 // By default, do not merge the transactions.
2147 * Optionally expand transactions which imply other effects. For example,
2148 * resigning from a revision in Differential implies removing yourself as
2151 protected function expandTransactions(
2152 PhabricatorLiskDAO
$object,
2156 foreach ($xactions as $xaction) {
2157 foreach ($this->expandTransaction($object, $xaction) as $expanded) {
2158 $results[] = $expanded;
2165 protected function expandTransaction(
2166 PhabricatorLiskDAO
$object,
2167 PhabricatorApplicationTransaction
$xaction) {
2168 return array($xaction);
2172 public function getExpandedSupportTransactions(
2173 PhabricatorLiskDAO
$object,
2174 PhabricatorApplicationTransaction
$xaction) {
2176 $xactions = array($xaction);
2177 $xactions = $this->expandSupportTransactions(
2181 if (count($xactions) == 1) {
2185 foreach ($xactions as $index => $cxaction) {
2186 if ($cxaction === $xaction) {
2187 unset($xactions[$index]);
2195 private function expandSupportTransactions(
2196 PhabricatorLiskDAO
$object,
2198 $this->loadSubscribers($object);
2200 $xactions = $this->applyImplicitCC($object, $xactions);
2202 $changes = $this->getRemarkupChanges($xactions);
2204 $subscribe_xaction = $this->buildSubscribeTransaction(
2208 if ($subscribe_xaction) {
2209 $xactions[] = $subscribe_xaction;
2212 // TODO: For now, this is just a placeholder.
2213 $engine = PhabricatorMarkupEngine
::getEngine('extract');
2214 $engine->setConfig('viewer', $this->requireActor());
2216 $block_xactions = $this->expandRemarkupBlockTransactions(
2222 foreach ($block_xactions as $xaction) {
2223 $xactions[] = $xaction;
2226 $file_xaction = $this->newFileTransaction(
2230 if ($file_xaction) {
2231 $xactions[] = $file_xaction;
2238 private function newFileTransaction(
2239 PhabricatorLiskDAO
$object,
2241 array $remarkup_changes) {
2243 assert_instances_of(
2245 'PhabricatorTransactionRemarkupChange');
2249 foreach ($remarkup_changes as $remarkup_change) {
2250 $metadata = $remarkup_change->getMetadata();
2252 $attached_phids = idx($metadata, 'attachedFilePHIDs');
2253 foreach ($attached_phids as $file_phid) {
2254 $new_map[$file_phid] = PhabricatorFileAttachment
::MODE_ATTACH
;
2258 $file_phids = $this->extractFilePHIDs($object, $xactions);
2259 foreach ($file_phids as $file_phid) {
2260 $new_map[$file_phid] = PhabricatorFileAttachment
::MODE_ATTACH
;
2267 $xaction = $object->getApplicationTransactionTemplate()
2268 ->setTransactionType(PhabricatorTransactions
::TYPE_FILE
)
2269 ->setNewValue($new_map);
2275 private function getRemarkupChanges(array $xactions) {
2278 foreach ($xactions as $key => $xaction) {
2279 foreach ($this->getRemarkupChangesFromTransaction($xaction) as $change) {
2280 $changes[] = $change;
2287 private function getRemarkupChangesFromTransaction(
2288 PhabricatorApplicationTransaction
$transaction) {
2289 return $transaction->getRemarkupChanges();
2292 private function expandRemarkupBlockTransactions(
2293 PhabricatorLiskDAO
$object,
2296 PhutilMarkupEngine
$engine) {
2298 $block_xactions = $this->expandCustomRemarkupBlockTransactions(
2304 $mentioned_phids = array();
2305 if ($this->shouldEnableMentions($object, $xactions)) {
2306 foreach ($changes as $change) {
2307 // Here, we don't care about processing only new mentions after an edit
2308 // because there is no way for an object to ever "unmention" itself on
2309 // another object, so we can ignore the old value.
2310 $engine->markupText($change->getNewValue());
2312 $mentioned_phids +
= $engine->getTextMetadata(
2313 PhabricatorObjectRemarkupRule
::KEY_MENTIONED_OBJECTS
,
2318 if (!$mentioned_phids) {
2319 return $block_xactions;
2322 $mentioned_objects = id(new PhabricatorObjectQuery())
2323 ->setViewer($this->getActor())
2324 ->withPHIDs($mentioned_phids)
2327 $unmentionable_map = $this->getUnmentionablePHIDMap();
2329 $mentionable_phids = array();
2330 if ($this->shouldEnableMentions($object, $xactions)) {
2331 foreach ($mentioned_objects as $mentioned_object) {
2332 if ($mentioned_object instanceof PhabricatorMentionableInterface
) {
2333 $mentioned_phid = $mentioned_object->getPHID();
2334 if (isset($unmentionable_map[$mentioned_phid])) {
2337 // don't let objects mention themselves
2338 if ($object->getPHID() && $mentioned_phid == $object->getPHID()) {
2341 $mentionable_phids[$mentioned_phid] = $mentioned_phid;
2346 if ($mentionable_phids) {
2347 $edge_type = PhabricatorObjectMentionsObjectEdgeType
::EDGECONST
;
2348 $block_xactions[] = newv(get_class(head($xactions)), array())
2349 ->setIgnoreOnNoEffect(true)
2350 ->setTransactionType(PhabricatorTransactions
::TYPE_EDGE
)
2351 ->setMetadataValue('edge:type', $edge_type)
2352 ->setNewValue(array('+' => $mentionable_phids));
2355 return $block_xactions;
2358 protected function expandCustomRemarkupBlockTransactions(
2359 PhabricatorLiskDAO
$object,
2362 PhutilMarkupEngine
$engine) {
2368 * Attempt to combine similar transactions into a smaller number of total
2369 * transactions. For example, two transactions which edit the title of an
2370 * object can be merged into a single edit.
2372 private function combineTransactions(array $xactions) {
2373 $stray_comments = array();
2377 foreach ($xactions as $key => $xaction) {
2378 $type = $xaction->getTransactionType();
2379 if (isset($types[$type])) {
2380 foreach ($types[$type] as $other_key) {
2381 $other_xaction = $result[$other_key];
2383 // Don't merge transactions with different authors. For example,
2384 // don't merge Herald transactions and owners transactions.
2385 if ($other_xaction->getAuthorPHID() != $xaction->getAuthorPHID()) {
2389 $merged = $this->mergeTransactions($result[$other_key], $xaction);
2391 $result[$other_key] = $merged;
2393 if ($xaction->getComment() &&
2394 ($xaction->getComment() !== $merged->getComment())) {
2395 $stray_comments[] = $xaction->getComment();
2398 if ($result[$other_key]->getComment() &&
2399 ($result[$other_key]->getComment() !== $merged->getComment())) {
2400 $stray_comments[] = $result[$other_key]->getComment();
2403 // Move on to the next transaction.
2408 $result[$key] = $xaction;
2409 $types[$type][] = $key;
2412 // If we merged any comments away, restore them.
2413 foreach ($stray_comments as $comment) {
2414 $xaction = newv(get_class(head($result)), array());
2415 $xaction->setTransactionType(PhabricatorTransactions
::TYPE_COMMENT
);
2416 $xaction->setComment($comment);
2417 $result[] = $xaction;
2420 return array_values($result);
2423 public function mergePHIDOrEdgeTransactions(
2424 PhabricatorApplicationTransaction
$u,
2425 PhabricatorApplicationTransaction
$v) {
2427 $result = $u->getNewValue();
2428 foreach ($v->getNewValue() as $key => $value) {
2429 if ($u->getTransactionType() == PhabricatorTransactions
::TYPE_EDGE
) {
2430 if (empty($result[$key])) {
2431 $result[$key] = $value;
2433 // We're merging two lists of edge adds, sets, or removes. Merge
2434 // them by merging individual PHIDs within them.
2435 $merged = $result[$key];
2437 foreach ($value as $dst => $v_spec) {
2438 if (empty($merged[$dst])) {
2439 $merged[$dst] = $v_spec;
2441 // Two transactions are trying to perform the same operation on
2442 // the same edge. Normalize the edge data and then merge it. This
2443 // allows transactions to specify how data merges execute in a
2446 $u_spec = $merged[$dst];
2448 if (!is_array($u_spec)) {
2449 $u_spec = array('dst' => $u_spec);
2451 if (!is_array($v_spec)) {
2452 $v_spec = array('dst' => $v_spec);
2455 $ux_data = idx($u_spec, 'data', array());
2456 $vx_data = idx($v_spec, 'data', array());
2458 $merged_data = $this->mergeEdgeData(
2459 $u->getMetadataValue('edge:type'),
2463 $u_spec['data'] = $merged_data;
2464 $merged[$dst] = $u_spec;
2468 $result[$key] = $merged;
2471 $result[$key] = array_merge($value, idx($result, $key, array()));
2474 $u->setNewValue($result);
2476 // When combining an "ignore" transaction with a normal transaction, make
2477 // sure we don't propagate the "ignore" flag.
2478 if (!$v->getIgnoreOnNoEffect()) {
2479 $u->setIgnoreOnNoEffect(false);
2485 protected function mergeEdgeData($type, array $u, array $v) {
2489 protected function getPHIDTransactionNewValue(
2490 PhabricatorApplicationTransaction
$xaction,
2493 if ($old !== null) {
2494 $old = array_fuse($old);
2496 $old = array_fuse($xaction->getOldValue());
2499 return $this->getPHIDList($old, $xaction->getNewValue());
2502 public function getPHIDList(array $old, array $new) {
2503 $new_add = idx($new, '+', array());
2505 $new_rem = idx($new, '-', array());
2507 $new_set = idx($new, '=', null);
2508 if ($new_set !== null) {
2509 $new_set = array_fuse($new_set);
2514 throw new Exception(
2516 "Invalid '%s' value for PHID transaction. Value should contain only ".
2517 "keys '%s' (add PHIDs), '%s' (remove PHIDs) and '%s' (set PHIDS).",
2526 foreach ($old as $phid) {
2527 if ($new_set !== null && empty($new_set[$phid])) {
2530 $result[$phid] = $phid;
2533 if ($new_set !== null) {
2534 foreach ($new_set as $phid) {
2535 $result[$phid] = $phid;
2539 foreach ($new_add as $phid) {
2540 $result[$phid] = $phid;
2543 foreach ($new_rem as $phid) {
2544 unset($result[$phid]);
2547 return array_values($result);
2550 protected function getEdgeTransactionNewValue(
2551 PhabricatorApplicationTransaction
$xaction) {
2553 $new = $xaction->getNewValue();
2554 $new_add = idx($new, '+', array());
2556 $new_rem = idx($new, '-', array());
2558 $new_set = idx($new, '=', null);
2562 throw new Exception(
2564 "Invalid '%s' value for Edge transaction. Value should contain only ".
2565 "keys '%s' (add edges), '%s' (remove edges) and '%s' (set edges).",
2572 $old = $xaction->getOldValue();
2574 $lists = array($new_set, $new_add, $new_rem);
2575 foreach ($lists as $list) {
2576 $this->checkEdgeList($list, $xaction->getMetadataValue('edge:type'));
2580 foreach ($old as $dst_phid => $edge) {
2581 if ($new_set !== null && empty($new_set[$dst_phid])) {
2584 $result[$dst_phid] = $this->normalizeEdgeTransactionValue(
2590 if ($new_set !== null) {
2591 foreach ($new_set as $dst_phid => $edge) {
2592 $result[$dst_phid] = $this->normalizeEdgeTransactionValue(
2599 foreach ($new_add as $dst_phid => $edge) {
2600 $result[$dst_phid] = $this->normalizeEdgeTransactionValue(
2606 foreach ($new_rem as $dst_phid => $edge) {
2607 unset($result[$dst_phid]);
2613 private function checkEdgeList($list, $edge_type) {
2617 foreach ($list as $key => $item) {
2618 if (phid_get_type($key) === PhabricatorPHIDConstants
::PHID_TYPE_UNKNOWN
) {
2619 throw new Exception(
2621 'Edge transactions must have destination PHIDs as in edge '.
2622 'lists (found key "%s" on transaction of type "%s").',
2626 if (!is_array($item) && $item !== $key) {
2627 throw new Exception(
2629 'Edge transactions must have PHIDs or edge specs as values '.
2630 '(found value "%s" on transaction of type "%s").',
2637 private function normalizeEdgeTransactionValue(
2638 PhabricatorApplicationTransaction
$xaction,
2642 if (!is_array($edge)) {
2643 if ($edge != $dst_phid) {
2644 throw new Exception(
2646 'Transaction edge data must either be the edge PHID or an edge '.
2647 'specification dictionary.'));
2651 foreach ($edge as $key => $value) {
2658 case 'dateModified':
2663 throw new Exception(
2665 'Transaction edge specification contains unexpected key "%s".',
2671 $edge['dst'] = $dst_phid;
2673 $edge_type = $xaction->getMetadataValue('edge:type');
2674 if (empty($edge['type'])) {
2675 $edge['type'] = $edge_type;
2677 if ($edge['type'] != $edge_type) {
2678 $this_type = $edge['type'];
2679 throw new Exception(
2681 "Edge transaction includes edge of type '%s', but ".
2682 "transaction is of type '%s'. Each edge transaction ".
2683 "must alter edges of only one type.",
2689 if (!isset($edge['data'])) {
2690 $edge['data'] = array();
2696 protected function sortTransactions(array $xactions) {
2700 // Move bare comments to the end, so the actions precede them.
2701 foreach ($xactions as $xaction) {
2702 $type = $xaction->getTransactionType();
2703 if ($type == PhabricatorTransactions
::TYPE_COMMENT
) {
2710 return array_values(array_merge($head, $tail));
2714 protected function filterTransactions(
2715 PhabricatorLiskDAO
$object,
2718 $type_comment = PhabricatorTransactions
::TYPE_COMMENT
;
2719 $type_mfa = PhabricatorTransactions
::TYPE_MFA
;
2721 $no_effect = array();
2722 $has_comment = false;
2723 $any_effect = false;
2725 $meta_xactions = array();
2726 foreach ($xactions as $key => $xaction) {
2727 if ($xaction->getTransactionType() === $type_mfa) {
2728 $meta_xactions[$key] = $xaction;
2732 if ($this->transactionHasEffect($object, $xaction)) {
2733 if ($xaction->getTransactionType() != $type_comment) {
2736 } else if ($xaction->getIgnoreOnNoEffect()) {
2737 unset($xactions[$key]);
2739 $no_effect[$key] = $xaction;
2742 if ($xaction->hasComment()) {
2743 $has_comment = true;
2747 // If every transaction is a meta-transaction applying to the transaction
2748 // group, these transactions are junk.
2749 if (count($meta_xactions) == count($xactions)) {
2750 $no_effect = $xactions;
2751 $any_effect = false;
2758 // If none of the transactions have an effect, the meta-transactions also
2759 // have no effect. Add them to the "no effect" list so we get a full set
2760 // of errors for everything.
2761 if (!$any_effect && !$has_comment) {
2762 $no_effect +
= $meta_xactions;
2765 if (!$this->getContinueOnNoEffect() && !$this->getIsPreview()) {
2766 throw new PhabricatorApplicationTransactionNoEffectException(
2772 if (!$any_effect && !$has_comment) {
2773 // If we only have empty comment transactions, just drop them all.
2777 foreach ($no_effect as $key => $xaction) {
2778 if ($xaction->hasComment()) {
2779 $xaction->setTransactionType($type_comment);
2780 $xaction->setOldValue(null);
2781 $xaction->setNewValue(null);
2783 unset($xactions[$key]);
2792 * Hook for validating transactions. This callback will be invoked for each
2793 * available transaction type, even if an edit does not apply any transactions
2794 * of that type. This allows you to raise exceptions when required fields are
2795 * missing, by detecting that the object has no field value and there is no
2796 * transaction which sets one.
2798 * @param PhabricatorLiskDAO Object being edited.
2799 * @param string Transaction type to validate.
2800 * @param list<PhabricatorApplicationTransaction> Transactions of given type,
2801 * which may be empty if the edit does not apply any transactions of the
2803 * @return list<PhabricatorApplicationTransactionValidationError> List of
2804 * validation errors.
2806 protected function validateTransaction(
2807 PhabricatorLiskDAO
$object,
2813 $xtype = $this->getModularTransactionType($type);
2815 $errors[] = $xtype->validateTransactions($object, $xactions);
2819 case PhabricatorTransactions
::TYPE_VIEW_POLICY
:
2820 $errors[] = $this->validatePolicyTransaction(
2824 PhabricatorPolicyCapability
::CAN_VIEW
);
2826 case PhabricatorTransactions
::TYPE_EDIT_POLICY
:
2827 $errors[] = $this->validatePolicyTransaction(
2831 PhabricatorPolicyCapability
::CAN_EDIT
);
2833 case PhabricatorTransactions
::TYPE_SPACE
:
2834 $errors[] = $this->validateSpaceTransactions(
2839 case PhabricatorTransactions
::TYPE_SUBTYPE
:
2840 $errors[] = $this->validateSubtypeTransactions(
2845 case PhabricatorTransactions
::TYPE_MFA
:
2846 $errors[] = $this->validateMFATransactions(
2851 case PhabricatorTransactions
::TYPE_CUSTOMFIELD
:
2853 foreach ($xactions as $xaction) {
2854 $groups[$xaction->getMetadataValue('customfield:key')][] = $xaction;
2857 $field_list = PhabricatorCustomField
::getObjectFields(
2859 PhabricatorCustomField
::ROLE_EDIT
);
2860 $field_list->setViewer($this->getActor());
2862 $role_xactions = PhabricatorCustomField
::ROLE_APPLICATIONTRANSACTIONS
;
2863 foreach ($field_list->getFields() as $field) {
2864 if (!$field->shouldEnableForRole($role_xactions)) {
2867 $errors[] = $field->validateApplicationTransactions(
2870 idx($groups, $field->getFieldKey(), array()));
2873 case PhabricatorTransactions
::TYPE_FILE
:
2874 $errors[] = $this->validateFileTransactions(
2881 return array_mergev($errors);
2884 private function validateFileTransactions(
2885 PhabricatorLiskDAO
$object,
2887 $transaction_type) {
2891 $mode_map = PhabricatorFileAttachment
::getModeList();
2892 $mode_map = array_fuse($mode_map);
2894 $file_phids = array();
2895 foreach ($xactions as $xaction) {
2896 $new = $xaction->getNewValue();
2898 if (!is_array($new)) {
2899 $errors[] = new PhabricatorApplicationTransactionValidationError(
2903 'File attachment transaction must have a map of files to '.
2904 'attachment modes, found "%s".',
2905 phutil_describe_type($new)),
2910 foreach ($new as $file_phid => $attachment_mode) {
2911 $file_phids[$file_phid] = $file_phid;
2913 if (is_string($attachment_mode) && isset($mode_map[$attachment_mode])) {
2917 if (!is_string($attachment_mode)) {
2918 $errors[] = new PhabricatorApplicationTransactionValidationError(
2922 'File attachment mode (for file "%s") is invalid. Expected '.
2923 'a string, found "%s".',
2925 phutil_describe_type($attachment_mode)),
2928 $errors[] = new PhabricatorApplicationTransactionValidationError(
2932 'File attachment mode "%s" (for file "%s") is invalid. Valid '.
2936 pht_list($mode_map)),
2943 $file_map = id(new PhabricatorFileQuery())
2944 ->setViewer($this->getActor())
2945 ->withPHIDs($file_phids)
2947 $file_map = mpull($file_map, null, 'getPHID');
2949 $file_map = array();
2952 foreach ($xactions as $xaction) {
2953 $new = $xaction->getNewValue();
2955 if (!is_array($new)) {
2959 foreach ($new as $file_phid => $attachment_mode) {
2960 if (isset($file_map[$file_phid])) {
2964 $errors[] = new PhabricatorApplicationTransactionValidationError(
2968 'File "%s" is invalid: it could not be loaded, or you do not '.
2969 'have permission to view it. You must be able to see a file to '.
2970 'attach it to an object.',
2980 public function validatePolicyTransaction(
2981 PhabricatorLiskDAO
$object,
2986 $actor = $this->requireActor();
2988 // Note $this->xactions is necessary; $xactions is $this->xactions of
2989 // $transaction_type
2990 $policy_object = $this->adjustObjectForPolicyChecks(
2994 // Make sure the user isn't editing away their ability to $capability this
2996 foreach ($xactions as $xaction) {
2998 PhabricatorPolicyFilter
::requireCapabilityWithForcedPolicy(
3002 $xaction->getNewValue());
3003 } catch (PhabricatorPolicyException
$ex) {
3004 $errors[] = new PhabricatorApplicationTransactionValidationError(
3008 'You can not select this %s policy, because you would no longer '.
3009 'be able to %s the object.',
3016 if ($this->getIsNewObject()) {
3018 $has_capability = PhabricatorPolicyFilter
::hasCapability(
3022 if (!$has_capability) {
3023 $errors[] = new PhabricatorApplicationTransactionValidationError(
3027 'The selected %s policy excludes you. Choose a %s policy '.
3028 'which allows you to %s the object.',
3040 private function validateSpaceTransactions(
3041 PhabricatorLiskDAO
$object,
3043 $transaction_type) {
3046 $actor = $this->getActor();
3048 $has_spaces = PhabricatorSpacesNamespaceQuery
::getViewerSpacesExist($actor);
3049 $actor_spaces = PhabricatorSpacesNamespaceQuery
::getViewerSpaces($actor);
3050 $active_spaces = PhabricatorSpacesNamespaceQuery
::getViewerActiveSpaces(
3052 foreach ($xactions as $xaction) {
3053 $space_phid = $xaction->getNewValue();
3055 if ($space_phid === null) {
3057 // The install doesn't have any spaces, so this is fine.
3061 // The install has some spaces, so every object needs to be put
3062 // in a valid space.
3063 $errors[] = new PhabricatorApplicationTransactionValidationError(
3066 pht('You must choose a space for this object.'),
3071 // If the PHID isn't `null`, it needs to be a valid space that the
3073 if (empty($actor_spaces[$space_phid])) {
3074 $errors[] = new PhabricatorApplicationTransactionValidationError(
3078 'You can not shift this object in the selected space, because '.
3079 'the space does not exist or you do not have access to it.'),
3081 } else if (empty($active_spaces[$space_phid])) {
3083 // It's OK to edit objects in an archived space, so just move on if
3084 // we aren't adjusting the value.
3085 $old_space_phid = $this->getTransactionOldValue($object, $xaction);
3086 if ($space_phid == $old_space_phid) {
3090 $errors[] = new PhabricatorApplicationTransactionValidationError(
3094 'You can not shift this object into the selected space, because '.
3095 'the space is archived. Objects can not be created inside (or '.
3096 'moved into) archived spaces.'),
3104 private function validateSubtypeTransactions(
3105 PhabricatorLiskDAO
$object,
3107 $transaction_type) {
3110 $map = $object->newEditEngineSubtypeMap();
3111 $old = $object->getEditEngineSubtype();
3112 foreach ($xactions as $xaction) {
3113 $new = $xaction->getNewValue();
3119 if (!$map->isValidSubtype($new)) {
3120 $errors[] = new PhabricatorApplicationTransactionValidationError(
3124 'The subtype "%s" is not a valid subtype.',
3134 private function validateMFATransactions(
3135 PhabricatorLiskDAO
$object,
3137 $transaction_type) {
3140 $factors = id(new PhabricatorAuthFactorConfigQuery())
3141 ->setViewer($this->getActor())
3142 ->withUserPHIDs(array($this->getActingAsPHID()))
3143 ->withFactorProviderStatuses(
3145 PhabricatorAuthFactorProviderStatus
::STATUS_ACTIVE
,
3146 PhabricatorAuthFactorProviderStatus
::STATUS_DEPRECATED
,
3150 foreach ($xactions as $xaction) {
3152 $errors[] = new PhabricatorApplicationTransactionValidationError(
3156 'You do not have any MFA factors attached to your account, so '.
3157 'you can not sign this transaction group with MFA. Add MFA to '.
3158 'your account in Settings.'),
3164 $this->setShouldRequireMFA(true);
3170 protected function adjustObjectForPolicyChecks(
3171 PhabricatorLiskDAO
$object,
3174 $copy = clone $object;
3176 foreach ($xactions as $xaction) {
3177 switch ($xaction->getTransactionType()) {
3178 case PhabricatorTransactions
::TYPE_SUBSCRIBERS
:
3179 $clone_xaction = clone $xaction;
3180 $clone_xaction->setOldValue(array_values($this->subscribers
));
3181 $clone_xaction->setNewValue(
3182 $this->getPHIDTransactionNewValue(
3185 PhabricatorPolicyRule
::passTransactionHintToRule(
3187 new PhabricatorSubscriptionsSubscribersPolicyRule(),
3188 array_fuse($clone_xaction->getNewValue()));
3191 case PhabricatorTransactions
::TYPE_SPACE
:
3192 $space_phid = $this->getTransactionNewValue($object, $xaction);
3193 $copy->setSpacePHID($space_phid);
3201 protected function validateAllTransactions(
3202 PhabricatorLiskDAO
$object,
3208 * Check for a missing text field.
3210 * A text field is missing if the object has no value and there are no
3211 * transactions which set a value, or if the transactions remove the value.
3212 * This method is intended to make implementing @{method:validateTransaction}
3215 * $missing = $this->validateIsEmptyTextField(
3216 * $object->getName(),
3219 * This will return `true` if the net effect of the object and transactions
3220 * is an empty field.
3222 * @param wild Current field value.
3223 * @param list<PhabricatorApplicationTransaction> Transactions editing the
3225 * @return bool True if the field will be an empty text field after edits.
3227 protected function validateIsEmptyTextField($field_value, array $xactions) {
3228 if (($field_value !== null && strlen($field_value)) && empty($xactions)) {
3232 if ($xactions && strlen(last($xactions)->getNewValue())) {
3240 /* -( Implicit CCs )------------------------------------------------------- */
3244 * When a user interacts with an object, we might want to add them to CC.
3246 final public function applyImplicitCC(
3247 PhabricatorLiskDAO
$object,
3250 if (!($object instanceof PhabricatorSubscribableInterface
)) {
3251 // If the object isn't subscribable, we can't CC them.
3255 $actor_phid = $this->getActingAsPHID();
3257 $type_user = PhabricatorPeopleUserPHIDType
::TYPECONST
;
3258 if (phid_get_type($actor_phid) != $type_user) {
3259 // Transactions by application actors like Herald, Harbormaster and
3260 // Diffusion should not CC the applications.
3264 if ($object->isAutomaticallySubscribed($actor_phid)) {
3265 // If they're auto-subscribed, don't CC them.
3270 foreach ($xactions as $xaction) {
3271 if ($this->shouldImplyCC($object, $xaction)) {
3278 // Only some types of actions imply a CC (like adding a comment).
3282 if ($object->getPHID()) {
3283 if (isset($this->subscribers
[$actor_phid])) {
3284 // If the user is already subscribed, don't implicitly CC them.
3288 $unsub = PhabricatorEdgeQuery
::loadDestinationPHIDs(
3290 PhabricatorObjectHasUnsubscriberEdgeType
::EDGECONST
);
3291 $unsub = array_fuse($unsub);
3292 if (isset($unsub[$actor_phid])) {
3293 // If the user has previously unsubscribed from this object explicitly,
3294 // don't implicitly CC them.
3299 $actor = $this->getActor();
3301 $user = id(new PhabricatorPeopleQuery())
3303 ->withPHIDs(array($actor_phid))
3309 // When a bot acts (usually via the API), don't automatically subscribe
3310 // them as a side effect. They can always subscribe explicitly if they
3311 // want, and bot subscriptions normally just clutter things up since bots
3312 // usually do not read email.
3313 if ($user->getIsSystemAgent()) {
3317 $xaction = newv(get_class(head($xactions)), array());
3318 $xaction->setTransactionType(PhabricatorTransactions
::TYPE_SUBSCRIBERS
);
3319 $xaction->setNewValue(array('+' => array($actor_phid)));
3321 array_unshift($xactions, $xaction);
3326 protected function shouldImplyCC(
3327 PhabricatorLiskDAO
$object,
3328 PhabricatorApplicationTransaction
$xaction) {
3330 return $xaction->isCommentTransaction();
3334 /* -( Sending Mail )------------------------------------------------------- */
3340 protected function shouldSendMail(
3341 PhabricatorLiskDAO
$object,
3350 private function buildMail(
3351 PhabricatorLiskDAO
$object,
3354 $email_to = $this->mailToPHIDs
;
3355 $email_cc = $this->mailCCPHIDs
;
3356 $email_cc = array_merge($email_cc, $this->heraldEmailPHIDs
);
3358 $unexpandable = $this->mailUnexpandablePHIDs
;
3359 if (!is_array($unexpandable)) {
3360 $unexpandable = array();
3363 $messages = $this->buildMailWithRecipients(
3370 $this->runHeraldMailRules($messages);
3375 private function buildMailWithRecipients(
3376 PhabricatorLiskDAO
$object,
3380 array $unexpandable) {
3382 $targets = $this->buildReplyHandler($object)
3383 ->setUnexpandablePHIDs($unexpandable)
3384 ->getMailTargets($email_to, $email_cc);
3386 // Set this explicitly before we start swapping out the effective actor.
3387 $this->setActingAsPHID($this->getActingAsPHID());
3389 $xaction_phids = mpull($xactions, 'getPHID');
3391 $messages = array();
3392 foreach ($targets as $target) {
3393 $original_actor = $this->getActor();
3395 $viewer = $target->getViewer();
3396 $this->setActor($viewer);
3397 $locale = PhabricatorEnv
::beginScopedLocale($viewer->getTranslation());
3402 // Reload the transactions for the current viewer.
3403 if ($xaction_phids) {
3404 $query = PhabricatorApplicationTransactionQuery
::newQueryForObject(
3407 $mail_xactions = $query
3408 ->setViewer($viewer)
3409 ->withObjectPHIDs(array($object->getPHID()))
3410 ->withPHIDs($xaction_phids)
3413 // Sort the mail transactions in the input order.
3414 $mail_xactions = mpull($mail_xactions, null, 'getPHID');
3415 $mail_xactions = array_select_keys($mail_xactions, $xaction_phids);
3416 $mail_xactions = array_values($mail_xactions);
3418 $mail_xactions = array();
3421 // Reload handles for the current viewer. This covers older code which
3422 // emits a list of handle PHIDs upfront.
3423 $this->loadHandles($mail_xactions);
3425 $mail = $this->buildMailForTarget($object, $mail_xactions, $target);
3428 if ($this->mustEncrypt
) {
3430 ->setMustEncrypt(true)
3431 ->setMustEncryptReasons($this->mustEncrypt
);
3434 } catch (Exception
$ex) {
3438 $this->setActor($original_actor);
3446 $messages[] = $mail;
3453 protected function getTransactionsForMail(
3454 PhabricatorLiskDAO
$object,
3459 private function buildMailForTarget(
3460 PhabricatorLiskDAO
$object,
3462 PhabricatorMailTarget
$target) {
3464 // Check if any of the transactions are visible for this viewer. If we
3465 // don't have any visible transactions, don't send the mail.
3467 $any_visible = false;
3468 foreach ($xactions as $xaction) {
3469 if (!$xaction->shouldHideForMail($xactions)) {
3470 $any_visible = true;
3475 if (!$any_visible) {
3479 $mail_xactions = $this->getTransactionsForMail($object, $xactions);
3481 $mail = $this->buildMailTemplate($object);
3482 $body = $this->buildMailBody($object, $mail_xactions);
3484 $mail_tags = $this->getMailTags($object, $mail_xactions);
3485 $action = $this->getMailAction($object, $mail_xactions);
3486 $stamps = $this->generateMailStamps($object, $this->mailStamps
);
3488 if (PhabricatorEnv
::getEnvConfig('metamta.email-preferences')) {
3489 $this->addEmailPreferenceSectionToMailBody(
3495 $muted_phids = $this->mailMutedPHIDs
;
3496 if (!is_array($muted_phids)) {
3497 $muted_phids = array();
3501 ->setSensitiveContent(false)
3502 ->setFrom($this->getActingAsPHID())
3503 ->setSubjectPrefix($this->getMailSubjectPrefix())
3504 ->setVarySubjectPrefix('['.$action.']')
3505 ->setThreadID($this->getMailThreadID($object), $this->getIsNewObject())
3506 ->setRelatedPHID($object->getPHID())
3507 ->setExcludeMailRecipientPHIDs($this->getExcludeMailRecipientPHIDs())
3508 ->setMutedPHIDs($muted_phids)
3509 ->setForceHeraldMailRecipientPHIDs($this->heraldForcedEmailPHIDs
)
3510 ->setMailTags($mail_tags)
3512 ->setBody($body->render())
3513 ->setHTMLBody($body->renderHTML());
3515 foreach ($body->getAttachments() as $attachment) {
3516 $mail->addAttachment($attachment);
3519 if ($this->heraldHeader
) {
3520 $mail->addHeader('X-Herald-Rules', $this->heraldHeader
);
3523 if ($object instanceof PhabricatorProjectInterface
) {
3524 $this->addMailProjectMetadata($object, $mail);
3527 if ($this->getParentMessageID()) {
3528 $mail->setParentMessageID($this->getParentMessageID());
3531 // If we have stamps, attach the raw dictionary version (not the actual
3532 // objects) to the mail so that debugging tools can see what we used to
3533 // render the final list.
3534 if ($this->mailStamps
) {
3535 $mail->setMailStampMetadata($this->mailStamps
);
3538 // If we have rendered stamps, attach them to the mail.
3540 $mail->setMailStamps($stamps);
3543 return $target->willSendMail($mail);
3546 private function addMailProjectMetadata(
3547 PhabricatorLiskDAO
$object,
3548 PhabricatorMetaMTAMail
$template) {
3550 $project_phids = PhabricatorEdgeQuery
::loadDestinationPHIDs(
3552 PhabricatorProjectObjectHasProjectEdgeType
::EDGECONST
);
3554 if (!$project_phids) {
3558 // TODO: This viewer isn't quite right. It would be slightly better to use
3559 // the mail recipient, but that's not very easy given the way rendering
3562 $handles = id(new PhabricatorHandleQuery())
3563 ->setViewer($this->requireActor())
3564 ->withPHIDs($project_phids)
3567 $project_tags = array();
3568 foreach ($handles as $handle) {
3569 if (!$handle->isComplete()) {
3572 $project_tags[] = '<'.$handle->getObjectName().'>';
3575 if (!$project_tags) {
3579 $project_tags = implode(', ', $project_tags);
3580 $template->addHeader('X-Phabricator-Projects', $project_tags);
3584 protected function getMailThreadID(PhabricatorLiskDAO
$object) {
3585 return $object->getPHID();
3592 protected function getStrongestAction(
3593 PhabricatorLiskDAO
$object,
3595 return head(msortv($xactions, 'newActionStrengthSortVector'));
3602 protected function buildReplyHandler(PhabricatorLiskDAO
$object) {
3603 throw new Exception(pht('Capability not supported.'));
3609 protected function getMailSubjectPrefix() {
3610 throw new Exception(pht('Capability not supported.'));
3617 protected function getMailTags(
3618 PhabricatorLiskDAO
$object,
3622 foreach ($xactions as $xaction) {
3623 $tags[] = $xaction->getMailTags();
3626 return array_mergev($tags);
3632 public function getMailTagsMap() {
3633 // TODO: We should move shared mail tags, like "comment", here.
3641 protected function getMailAction(
3642 PhabricatorLiskDAO
$object,
3644 return $this->getStrongestAction($object, $xactions)->getActionName();
3651 protected function buildMailTemplate(PhabricatorLiskDAO
$object) {
3652 throw new Exception(pht('Capability not supported.'));
3659 protected function getMailTo(PhabricatorLiskDAO
$object) {
3660 throw new Exception(pht('Capability not supported.'));
3664 protected function newMailUnexpandablePHIDs(PhabricatorLiskDAO
$object) {
3672 protected function getMailCC(PhabricatorLiskDAO
$object) {
3674 $has_support = false;
3676 if ($object instanceof PhabricatorSubscribableInterface
) {
3677 $phid = $object->getPHID();
3678 $phids[] = PhabricatorSubscribersQuery
::loadSubscribersForPHID($phid);
3679 $has_support = true;
3682 if ($object instanceof PhabricatorProjectInterface
) {
3683 $project_phids = PhabricatorEdgeQuery
::loadDestinationPHIDs(
3685 PhabricatorProjectObjectHasProjectEdgeType
::EDGECONST
);
3687 if ($project_phids) {
3688 $projects = id(new PhabricatorProjectQuery())
3689 ->setViewer(PhabricatorUser
::getOmnipotentUser())
3690 ->withPHIDs($project_phids)
3691 ->needWatchers(true)
3694 $watcher_phids = array();
3695 foreach ($projects as $project) {
3696 foreach ($project->getAllAncestorWatcherPHIDs() as $phid) {
3697 $watcher_phids[$phid] = $phid;
3701 if ($watcher_phids) {
3702 // We need to do a visibility check for all the watchers, as
3703 // watching a project is not a guarantee that you can see objects
3704 // associated with it.
3705 $users = id(new PhabricatorPeopleQuery())
3706 ->setViewer($this->requireActor())
3707 ->withPHIDs($watcher_phids)
3710 $watchers = array();
3711 foreach ($users as $user) {
3712 $can_see = PhabricatorPolicyFilter
::hasCapability(
3715 PhabricatorPolicyCapability
::CAN_VIEW
);
3717 $watchers[] = $user->getPHID();
3720 $phids[] = $watchers;
3724 $has_support = true;
3727 if (!$has_support) {
3728 throw new Exception(
3729 pht('The object being edited does not implement any standard '.
3730 'interfaces (like PhabricatorSubscribableInterface) which allow '.
3731 'CCs to be generated automatically. Override the "getMailCC()" '.
3732 'method and generate CCs explicitly.'));
3735 return array_mergev($phids);
3742 protected function buildMailBody(
3743 PhabricatorLiskDAO
$object,
3746 $body = id(new PhabricatorMetaMTAMailBody())
3747 ->setViewer($this->requireActor())
3748 ->setContextObject($object);
3750 $button_label = $this->getObjectLinkButtonLabelForMail($object);
3751 $button_uri = $this->getObjectLinkButtonURIForMail($object);
3753 $this->addHeadersAndCommentsToMailBody(
3759 $this->addCustomFieldsToMailBody($body, $object, $xactions);
3764 protected function getObjectLinkButtonLabelForMail(
3765 PhabricatorLiskDAO
$object) {
3769 protected function getObjectLinkButtonURIForMail(
3770 PhabricatorLiskDAO
$object) {
3772 // Most objects define a "getURI()" method which does what we want, but
3773 // this isn't formally part of an interface at time of writing. Try to
3774 // call the method, expecting an exception if it does not exist.
3777 $uri = $object->getURI();
3778 return PhabricatorEnv
::getProductionURI($uri);
3779 } catch (Exception
$ex) {
3787 protected function addEmailPreferenceSectionToMailBody(
3788 PhabricatorMetaMTAMailBody
$body,
3789 PhabricatorLiskDAO
$object,
3792 $href = PhabricatorEnv
::getProductionURI(
3793 '/settings/panel/emailpreferences/');
3794 $body->addLinkSection(pht('EMAIL PREFERENCES'), $href);
3801 protected function addHeadersAndCommentsToMailBody(
3802 PhabricatorMetaMTAMailBody
$body,
3804 $object_label = null,
3805 $object_uri = null) {
3807 // First, remove transactions which shouldn't be rendered in mail.
3808 foreach ($xactions as $key => $xaction) {
3809 if ($xaction->shouldHideForMail($xactions)) {
3810 unset($xactions[$key]);
3815 $headers_html = array();
3816 $comments = array();
3819 $seen_comment = false;
3820 foreach ($xactions as $xaction) {
3822 // Most mail has zero or one comments. In these cases, we render the
3823 // "alice added a comment." transaction in the header, like a normal
3826 // Some mail, like Differential undraft mail or "!history" mail, may
3827 // have two or more comments. In these cases, we'll put the first
3828 // "alice added a comment." transaction in the header normally, but
3829 // move the other transactions down so they provide context above the
3832 $comment = $this->getBodyForTextMail($xaction);
3833 if ($comment !== null) {
3835 $comments[] = array(
3836 'xaction' => $xaction,
3837 'comment' => $comment,
3838 'initial' => !$seen_comment,
3841 $is_comment = false;
3844 if (!$is_comment ||
!$seen_comment) {
3845 $header = $this->getTitleForTextMail($xaction);
3846 if ($header !== null) {
3847 $headers[] = $header;
3850 $header_html = $this->getTitleForHTMLMail($xaction);
3851 if ($header_html !== null) {
3852 $headers_html[] = $header_html;
3856 if ($xaction->hasChangeDetailsForMail()) {
3857 $details[] = $xaction;
3861 $seen_comment = true;
3865 $headers_text = implode("\n", $headers);
3866 $body->addRawPlaintextSection($headers_text);
3868 $headers_html = phutil_implode_html(phutil_tag('br'), $headers_html);
3870 $header_button = null;
3871 if ($object_label !== null && $object_uri !== null) {
3872 $button_style = array(
3873 'text-decoration: none;',
3874 'padding: 4px 8px;',
3875 'margin: 0 8px 8px;',
3878 'font-weight: bold;',
3879 'border-radius: 3px;',
3880 'background-color: #F7F7F9;',
3881 'background-image: linear-gradient(to bottom,#fff,#f1f0f1);',
3882 'display: inline-block;',
3883 'border: 1px solid rgba(71,87,120,.2);',
3886 $header_button = phutil_tag(
3889 'style' => implode(' ', $button_style),
3890 'href' => $object_uri,
3895 $xactions_style = array();
3897 $header_action = phutil_tag(
3902 $header_action = phutil_tag(
3905 'style' => implode(' ', $xactions_style),
3909 // Add an extra newline to prevent the "View Object" button from
3910 // running into the transaction text in Mail.app text snippet
3915 $headers_html = phutil_tag(
3918 phutil_tag('tr', array(), array($header_action, $header_button)));
3920 $body->addRawHTMLSection($headers_html);
3922 foreach ($comments as $spec) {
3923 $xaction = $spec['xaction'];
3924 $comment = $spec['comment'];
3925 $is_initial = $spec['initial'];
3927 // If this is not the first comment in the mail, add the header showing
3928 // who wrote the comment immediately above the comment.
3930 $header = $this->getTitleForTextMail($xaction);
3931 if ($header !== null) {
3932 $body->addRawPlaintextSection($header);
3935 $header_html = $this->getTitleForHTMLMail($xaction);
3936 if ($header_html !== null) {
3937 $body->addRawHTMLSection($header_html);
3941 $body->addRemarkupSection(null, $comment);
3944 foreach ($details as $xaction) {
3945 $details = $xaction->renderChangeDetailsForMail($body->getViewer());
3946 if ($details !== null) {
3947 $label = $this->getMailDiffSectionHeader($xaction);
3948 $body->addHTMLSection($label, $details);
3954 private function getMailDiffSectionHeader($xaction) {
3955 $type = $xaction->getTransactionType();
3957 $xtype = $this->getModularTransactionType($type);
3959 return $xtype->getMailDiffSectionHeader();
3962 return pht('EDIT DETAILS');
3968 protected function addCustomFieldsToMailBody(
3969 PhabricatorMetaMTAMailBody
$body,
3970 PhabricatorLiskDAO
$object,
3973 if ($object instanceof PhabricatorCustomFieldInterface
) {
3974 $field_list = PhabricatorCustomField
::getObjectFields(
3976 PhabricatorCustomField
::ROLE_TRANSACTIONMAIL
);
3977 $field_list->setViewer($this->getActor());
3978 $field_list->readFieldsFromStorage($object);
3980 foreach ($field_list->getFields() as $field) {
3981 $field->updateTransactionMailBody(
3993 private function runHeraldMailRules(array $messages) {
3994 foreach ($messages as $message) {
3995 $engine = new HeraldEngine();
3996 $adapter = id(new PhabricatorMailOutboundMailHeraldAdapter())
3997 ->setObject($message);
3999 $rules = $engine->loadRulesForAdapter($adapter);
4000 $effects = $engine->applyRules($rules, $adapter);
4001 $engine->applyEffects($effects, $adapter, $rules);
4006 /* -( Publishing Feed Stories )-------------------------------------------- */
4012 protected function shouldPublishFeedStory(
4013 PhabricatorLiskDAO
$object,
4022 protected function getFeedStoryType() {
4023 return 'PhabricatorApplicationTransactionFeedStory';
4030 protected function getFeedRelatedPHIDs(
4031 PhabricatorLiskDAO
$object,
4036 $this->getActingAsPHID(),
4039 if ($object instanceof PhabricatorProjectInterface
) {
4040 $project_phids = PhabricatorEdgeQuery
::loadDestinationPHIDs(
4042 PhabricatorProjectObjectHasProjectEdgeType
::EDGECONST
);
4043 foreach ($project_phids as $project_phid) {
4044 $phids[] = $project_phid;
4055 protected function getFeedNotifyPHIDs(
4056 PhabricatorLiskDAO
$object,
4059 // If some transactions are forcing notification delivery, add the forced
4060 // recipients to the notify list.
4061 $force_list = array();
4062 foreach ($xactions as $xaction) {
4063 $force_phids = $xaction->getForceNotifyPHIDs();
4065 if (!$force_phids) {
4069 foreach ($force_phids as $force_phid) {
4070 $force_list[] = $force_phid;
4074 $to_list = $this->getMailTo($object);
4075 $cc_list = $this->getMailCC($object);
4077 $full_list = array_merge($force_list, $to_list, $cc_list);
4078 $full_list = array_fuse($full_list);
4080 return array_keys($full_list);
4087 protected function getFeedStoryData(
4088 PhabricatorLiskDAO
$object,
4091 $xactions = msortv($xactions, 'newActionStrengthSortVector');
4094 'objectPHID' => $object->getPHID(),
4095 'transactionPHIDs' => mpull($xactions, 'getPHID'),
4103 protected function publishFeedStory(
4104 PhabricatorLiskDAO
$object,
4106 array $mailed_phids) {
4108 // Remove transactions which don't publish feed stories or notifications.
4109 // These never show up anywhere, so we don't need to do anything with them.
4110 foreach ($xactions as $key => $xaction) {
4111 if (!$xaction->shouldHideForFeed()) {
4115 if (!$xaction->shouldHideForNotifications()) {
4119 unset($xactions[$key]);
4126 $related_phids = $this->feedRelatedPHIDs
;
4127 $subscribed_phids = $this->feedNotifyPHIDs
;
4129 // Remove muted users from the subscription list so they don't get
4130 // notifications, either.
4131 $muted_phids = $this->mailMutedPHIDs
;
4132 if (!is_array($muted_phids)) {
4133 $muted_phids = array();
4135 $subscribed_phids = array_fuse($subscribed_phids);
4136 foreach ($muted_phids as $muted_phid) {
4137 unset($subscribed_phids[$muted_phid]);
4139 $subscribed_phids = array_values($subscribed_phids);
4141 $story_type = $this->getFeedStoryType();
4142 $story_data = $this->getFeedStoryData($object, $xactions);
4144 $unexpandable_phids = $this->mailUnexpandablePHIDs
;
4145 if (!is_array($unexpandable_phids)) {
4146 $unexpandable_phids = array();
4149 id(new PhabricatorFeedStoryPublisher())
4150 ->setStoryType($story_type)
4151 ->setStoryData($story_data)
4152 ->setStoryTime(time())
4153 ->setStoryAuthorPHID($this->getActingAsPHID())
4154 ->setRelatedPHIDs($related_phids)
4155 ->setPrimaryObjectPHID($object->getPHID())
4156 ->setSubscribedPHIDs($subscribed_phids)
4157 ->setUnexpandablePHIDs($unexpandable_phids)
4158 ->setMailRecipientPHIDs($mailed_phids)
4159 ->setMailTags($this->getMailTags($object, $xactions))
4164 /* -( Search Index )------------------------------------------------------- */
4170 protected function supportsSearch() {
4175 /* -( Herald Integration )-------------------------------------------------- */
4178 protected function shouldApplyHeraldRules(
4179 PhabricatorLiskDAO
$object,
4184 protected function buildHeraldAdapter(
4185 PhabricatorLiskDAO
$object,
4187 throw new Exception(pht('No herald adapter specified.'));
4190 private function setHeraldAdapter(HeraldAdapter
$adapter) {
4191 $this->heraldAdapter
= $adapter;
4195 protected function getHeraldAdapter() {
4196 return $this->heraldAdapter
;
4199 private function setHeraldTranscript(HeraldTranscript
$transcript) {
4200 $this->heraldTranscript
= $transcript;
4204 protected function getHeraldTranscript() {
4205 return $this->heraldTranscript
;
4208 private function applyHeraldRules(
4209 PhabricatorLiskDAO
$object,
4212 $adapter = $this->buildHeraldAdapter($object, $xactions)
4213 ->setContentSource($this->getContentSource())
4214 ->setIsNewObject($this->getIsNewObject())
4215 ->setActingAsPHID($this->getActingAsPHID())
4216 ->setAppliedTransactions($xactions);
4218 if ($this->getApplicationEmail()) {
4219 $adapter->setApplicationEmail($this->getApplicationEmail());
4222 // If this editor is operating in silent mode, tell Herald that we aren't
4223 // going to send any mail. This allows it to skip "the first time this
4224 // rule matches, send me an email" rules which would otherwise match even
4225 // though we aren't going to send any mail.
4226 if ($this->getIsSilent()) {
4227 $adapter->setForbiddenAction(
4228 HeraldMailableState
::STATECONST
,
4229 HeraldCoreStateReasons
::REASON_SILENT
);
4232 $xscript = HeraldEngine
::loadAndApplyRules($adapter);
4234 $this->setHeraldAdapter($adapter);
4235 $this->setHeraldTranscript($xscript);
4237 if ($adapter instanceof HarbormasterBuildableAdapterInterface
) {
4238 $buildable_phid = $adapter->getHarbormasterBuildablePHID();
4240 HarbormasterBuildable
::applyBuildPlans(
4242 $adapter->getHarbormasterContainerPHID(),
4243 $adapter->getQueuedHarbormasterBuildRequests());
4245 // Whether we queued any builds or not, any automatic buildable for this
4246 // object is now done preparing builds and can transition into a
4247 // completed status.
4248 $buildables = id(new HarbormasterBuildableQuery())
4249 ->setViewer(PhabricatorUser
::getOmnipotentUser())
4250 ->withManualBuildables(false)
4251 ->withBuildablePHIDs(array($buildable_phid))
4253 foreach ($buildables as $buildable) {
4254 // If this buildable has already moved beyond preparation, we don't
4255 // need to nudge it again.
4256 if (!$buildable->isPreparing()) {
4259 $buildable->sendMessage(
4261 HarbormasterMessageType
::BUILDABLE_BUILD
,
4266 $this->mustEncrypt
= $adapter->getMustEncryptReasons();
4268 // See PHI1134. Propagate "Must Encrypt" state to sub-editors.
4269 foreach ($this->subEditors
as $sub_editor) {
4270 $sub_editor->mustEncrypt
= $this->mustEncrypt
;
4273 $apply_xactions = $this->didApplyHeraldRules($object, $adapter, $xscript);
4274 assert_instances_of($apply_xactions, 'PhabricatorApplicationTransaction');
4276 $queue_xactions = $adapter->getQueuedTransactions();
4279 array_values($apply_xactions),
4280 array_values($queue_xactions));
4283 protected function didApplyHeraldRules(
4284 PhabricatorLiskDAO
$object,
4285 HeraldAdapter
$adapter,
4286 HeraldTranscript
$transcript) {
4291 /* -( Custom Fields )------------------------------------------------------ */
4297 private function getCustomFieldForTransaction(
4298 PhabricatorLiskDAO
$object,
4299 PhabricatorApplicationTransaction
$xaction) {
4301 $field_key = $xaction->getMetadataValue('customfield:key');
4303 throw new Exception(
4305 "Custom field transaction has no '%s'!",
4306 'customfield:key'));
4309 $field = PhabricatorCustomField
::getObjectField(
4311 PhabricatorCustomField
::ROLE_APPLICATIONTRANSACTIONS
,
4315 throw new Exception(
4317 "Custom field transaction has invalid '%s'; field '%s' ".
4318 "is disabled or does not exist.",
4323 if (!$field->shouldAppearInApplicationTransactions()) {
4324 throw new Exception(
4326 "Custom field transaction '%s' does not implement ".
4327 "integration for %s.",
4329 'ApplicationTransactions'));
4332 $field->setViewer($this->getActor());
4338 /* -( Files )-------------------------------------------------------------- */
4342 * Extract the PHIDs of any files which these transactions attach.
4346 private function extractFilePHIDs(
4347 PhabricatorLiskDAO
$object,
4352 foreach ($xactions as $xaction) {
4353 $type = $xaction->getTransactionType();
4355 $xtype = $this->getModularTransactionType($type);
4357 $phids[] = $xtype->extractFilePHIDs($object, $xaction->getNewValue());
4359 $phids[] = $this->extractFilePHIDsFromCustomTransaction(
4365 $phids = array_unique(array_filter(array_mergev($phids)));
4373 protected function extractFilePHIDsFromCustomTransaction(
4374 PhabricatorLiskDAO
$object,
4375 PhabricatorApplicationTransaction
$xaction) {
4380 private function applyInverseEdgeTransactions(
4381 PhabricatorLiskDAO
$object,
4382 PhabricatorApplicationTransaction
$xaction,
4385 $old = $xaction->getOldValue();
4386 $new = $xaction->getNewValue();
4388 $add = array_keys(array_diff_key($new, $old));
4389 $rem = array_keys(array_diff_key($old, $new));
4391 $add = array_fuse($add);
4392 $rem = array_fuse($rem);
4395 $nodes = id(new PhabricatorObjectQuery())
4396 ->setViewer($this->requireActor())
4400 $object_phid = $object->getPHID();
4402 foreach ($nodes as $node) {
4403 if (!($node instanceof PhabricatorApplicationTransactionInterface
)) {
4407 if ($node instanceof PhabricatorUser
) {
4408 // TODO: At least for now, don't record inverse edge transactions
4409 // for users (for example, "alincoln joined project X"): Feed fills
4410 // this role instead.
4414 $node_phid = $node->getPHID();
4415 $editor = $node->getApplicationTransactionEditor();
4416 $template = $node->getApplicationTransactionTemplate();
4418 // See T13082. We have to build these transactions with synthetic values
4419 // because we've already applied the actual edit to the edge database
4420 // table. If we try to apply this transaction naturally, it will no-op
4421 // itself because it doesn't have any effect.
4423 $edge_query = id(new PhabricatorEdgeQuery())
4424 ->withSourcePHIDs(array($node_phid))
4425 ->withEdgeTypes(array($inverse_type));
4427 $edge_query->execute();
4429 $edge_phids = $edge_query->getDestinationPHIDs();
4430 $edge_phids = array_fuse($edge_phids);
4432 $new_phids = $edge_phids;
4433 $old_phids = $edge_phids;
4435 if (isset($add[$node_phid])) {
4436 unset($old_phids[$object_phid]);
4438 $old_phids[$object_phid] = $object_phid;
4442 ->setTransactionType($xaction->getTransactionType())
4443 ->setMetadataValue('edge:type', $inverse_type)
4444 ->setOldValue($old_phids)
4445 ->setNewValue($new_phids);
4447 $editor = $this->newSubEditor($editor)
4448 ->setContinueOnNoEffect(true)
4449 ->setContinueOnMissingFields(true)
4450 ->setIsInverseEdgeEditor(true);
4452 $editor->applyTransactions($node, array($template));
4457 /* -( Workers )------------------------------------------------------------ */
4461 * Load any object state which is required to publish transactions.
4463 * This hook is invoked in the main process before we compute data related
4464 * to publishing transactions (like email "To" and "CC" lists), and again in
4465 * the worker before publishing occurs.
4467 * @return object Publishable object.
4470 protected function willPublish(PhabricatorLiskDAO
$object, array $xactions) {
4476 * Convert the editor state to a serializable dictionary which can be passed
4479 * This data will be loaded with @{method:loadWorkerState} in the worker.
4481 * @return dict<string, wild> Serializable editor state.
4484 private function getWorkerState() {
4486 foreach ($this->getAutomaticStateProperties() as $property) {
4487 $state[$property] = $this->$property;
4490 $custom_state = $this->getCustomWorkerState();
4491 $custom_encoding = $this->getCustomWorkerStateEncoding();
4494 'excludeMailRecipientPHIDs' => $this->getExcludeMailRecipientPHIDs(),
4495 'custom' => $this->encodeStateForStorage($custom_state, $custom_encoding),
4496 'custom.encoding' => $custom_encoding,
4504 * Hook; return custom properties which need to be passed to workers.
4506 * @return dict<string, wild> Custom properties.
4509 protected function getCustomWorkerState() {
4515 * Hook; return storage encoding for custom properties which need to be
4516 * passed to workers.
4518 * This primarily allows binary data to be passed to workers and survive
4521 * @return dict<string, string> Property encodings.
4524 protected function getCustomWorkerStateEncoding() {
4530 * Load editor state using a dictionary emitted by @{method:getWorkerState}.
4532 * This method is used to load state when running worker operations.
4534 * @param dict<string, wild> Editor state, from @{method:getWorkerState}.
4538 final public function loadWorkerState(array $state) {
4539 foreach ($this->getAutomaticStateProperties() as $property) {
4540 $this->$property = idx($state, $property);
4543 $exclude = idx($state, 'excludeMailRecipientPHIDs', array());
4544 $this->setExcludeMailRecipientPHIDs($exclude);
4546 $custom_state = idx($state, 'custom', array());
4547 $custom_encodings = idx($state, 'custom.encoding', array());
4548 $custom = $this->decodeStateFromStorage($custom_state, $custom_encodings);
4550 $this->loadCustomWorkerState($custom);
4557 * Hook; set custom properties on the editor from data emitted by
4558 * @{method:getCustomWorkerState}.
4560 * @param dict<string, wild> Custom state,
4561 * from @{method:getCustomWorkerState}.
4565 protected function loadCustomWorkerState(array $state) {
4571 * Get a list of object properties which should be automatically sent to
4572 * workers in the state data.
4574 * These properties will be automatically stored and loaded by the editor in
4577 * @return list<string> List of properties.
4580 private function getAutomaticStateProperties() {
4585 'heraldForcedEmailPHIDs',
4591 'feedShouldPublish',
4595 'mailUnexpandablePHIDs',
4604 * Apply encodings prior to storage.
4606 * See @{method:getCustomWorkerStateEncoding}.
4608 * @param map<string, wild> Map of values to encode.
4609 * @param map<string, string> Map of encodings to apply.
4610 * @return map<string, wild> Map of encoded values.
4613 private function encodeStateForStorage(
4617 foreach ($state as $key => $value) {
4618 $encoding = idx($encodings, $key);
4619 switch ($encoding) {
4620 case self
::STORAGE_ENCODING_BINARY
:
4621 // The mechanics of this encoding (serialize + base64) are a little
4622 // awkward, but it allows us encode arrays and still be JSON-safe
4623 // with binary data.
4625 $value = @serialize
($value);
4626 if ($value === false) {
4627 throw new Exception(
4629 'Failed to serialize() value for key "%s".',
4633 $value = base64_encode($value);
4634 if ($value === false) {
4635 throw new Exception(
4637 'Failed to base64 encode value for key "%s".',
4642 $state[$key] = $value;
4650 * Undo storage encoding applied when storing state.
4652 * See @{method:getCustomWorkerStateEncoding}.
4654 * @param map<string, wild> Map of encoded values.
4655 * @param map<string, string> Map of encodings.
4656 * @return map<string, wild> Map of decoded values.
4659 private function decodeStateFromStorage(
4663 foreach ($state as $key => $value) {
4664 $encoding = idx($encodings, $key);
4665 switch ($encoding) {
4666 case self
::STORAGE_ENCODING_BINARY
:
4667 $value = base64_decode($value);
4668 if ($value === false) {
4669 throw new Exception(
4671 'Failed to base64_decode() value for key "%s".',
4675 $value = unserialize($value);
4678 $state[$key] = $value;
4686 * Remove conflicts from a list of projects.
4688 * Objects aren't allowed to be tagged with multiple milestones in the same
4689 * group, nor projects such that one tag is the ancestor of any other tag.
4690 * If the list of PHIDs include mutually exclusive projects, remove the
4691 * conflicting projects.
4693 * @param list<phid> List of project PHIDs.
4694 * @return list<phid> List with conflicts removed.
4696 private function applyProjectConflictRules(array $phids) {
4701 // Overall, the last project in the list wins in cases of conflict (so when
4702 // you add something, the thing you just added sticks and removes older
4705 // Beyond that, there are two basic cases:
4707 // Milestones: An object can't be in "A > Sprint 3" and "A > Sprint 4".
4708 // If multiple projects are milestones of the same parent, we only keep the
4711 // Ancestor: You can't be in "A" and "A > B". If "A > B" comes later
4712 // in the list, we remove "A" and keep "A > B". If "A" comes later, we
4713 // remove "A > B" and keep "A".
4715 // Note that it's OK to be in "A > B" and "A > C". There's only a conflict
4716 // if one project is an ancestor of another. It's OK to have something
4717 // tagged with multiple projects which share a common ancestor, so long as
4718 // they are not mutual ancestors.
4720 $viewer = PhabricatorUser
::getOmnipotentUser();
4722 $projects = id(new PhabricatorProjectQuery())
4723 ->setViewer($viewer)
4724 ->withPHIDs(array_keys($phids))
4726 $projects = mpull($projects, null, 'getPHID');
4728 // We're going to build a map from each project with milestones to the last
4729 // milestone in the list. This last milestone is the milestone we'll keep.
4730 $milestone_map = array();
4732 // We're going to build a set of the projects which have no descendants
4733 // later in the list. This allows us to apply both ancestor rules.
4734 $ancestor_map = array();
4736 foreach ($phids as $phid => $ignored) {
4737 $project = idx($projects, $phid);
4742 // This is the last milestone we've seen, so set it as the selection for
4743 // the project's parent. This might be setting a new value or overwriting
4744 // an earlier value.
4745 if ($project->isMilestone()) {
4746 $parent_phid = $project->getParentProjectPHID();
4747 $milestone_map[$parent_phid] = $phid;
4750 // Since this is the last item in the list we've examined so far, add it
4751 // to the set of projects with no later descendants.
4752 $ancestor_map[$phid] = $phid;
4754 // Remove any ancestors from the set, since this is a later descendant.
4755 foreach ($project->getAncestorProjects() as $ancestor) {
4756 $ancestor_phid = $ancestor->getPHID();
4757 unset($ancestor_map[$ancestor_phid]);
4761 // Now that we've built the maps, we can throw away all the projects which
4763 foreach ($phids as $phid => $ignored) {
4764 $project = idx($projects, $phid);
4767 // If a PHID is invalid, we just leave it as-is. We could clean it up,
4768 // but leaving it untouched is less likely to cause collateral damage.
4772 // If this was a milestone, check if it was the last milestone from its
4773 // group in the list. If not, remove it from the list.
4774 if ($project->isMilestone()) {
4775 $parent_phid = $project->getParentProjectPHID();
4776 if ($milestone_map[$parent_phid] !== $phid) {
4777 unset($phids[$phid]);
4782 // If a later project in the list is a subproject of this one, it will
4783 // have removed ancestors from the map. If this project does not point
4784 // at itself in the ancestor map, it should be discarded in favor of a
4785 // subproject that comes later.
4786 if (idx($ancestor_map, $phid) !== $phid) {
4787 unset($phids[$phid]);
4791 // If a later project in the list is an ancestor of this one, it will
4792 // have added itself to the map. If any ancestor of this project points
4793 // at itself in the map, this project should be discarded in favor of
4794 // that later ancestor.
4795 foreach ($project->getAncestorProjects() as $ancestor) {
4796 $ancestor_phid = $ancestor->getPHID();
4797 if (isset($ancestor_map[$ancestor_phid])) {
4798 unset($phids[$phid]);
4808 * When the view policy for an object is changed, scramble the secret keys
4809 * for attached files to invalidate existing URIs.
4811 private function scrambleFileSecrets($object) {
4812 // If this is a newly created object, we don't need to scramble anything
4813 // since it couldn't have been previously published.
4814 if ($this->getIsNewObject()) {
4818 // If the object is a file itself, scramble it.
4819 if ($object instanceof PhabricatorFile
) {
4820 if ($this->shouldScramblePolicy($object->getViewPolicy())) {
4821 $object->scrambleSecret();
4826 $omnipotent_viewer = PhabricatorUser
::getOmnipotentUser();
4828 $files = id(new PhabricatorFileQuery())
4829 ->setViewer($omnipotent_viewer)
4830 ->withAttachedObjectPHIDs(array($object->getPHID()))
4832 foreach ($files as $file) {
4833 $view_policy = $file->getViewPolicy();
4834 if ($this->shouldScramblePolicy($view_policy)) {
4835 $file->scrambleSecret();
4843 * Check if a policy is strong enough to justify scrambling. Objects which
4844 * are set to very open policies don't need to scramble their files, and
4845 * files with very open policies don't need to be scrambled when associated
4848 private function shouldScramblePolicy($policy) {
4850 case PhabricatorPolicies
::POLICY_PUBLIC
:
4851 case PhabricatorPolicies
::POLICY_USER
:
4858 private function updateWorkboardColumns($object, $const, $old, $new) {
4859 // If an object is removed from a project, remove it from any proxy
4860 // columns for that project. This allows a task which is moved up from a
4861 // milestone to the parent to move back into the "Backlog" column on the
4862 // parent workboard.
4864 if ($const != PhabricatorProjectObjectHasProjectEdgeType
::EDGECONST
) {
4868 // TODO: This should likely be some future WorkboardInterface.
4869 $appears_on_workboards = ($object instanceof ManiphestTask
);
4870 if (!$appears_on_workboards) {
4874 $removed_phids = array_keys(array_diff_key($old, $new));
4875 if (!$removed_phids) {
4879 // Find any proxy columns for the removed projects.
4880 $proxy_columns = id(new PhabricatorProjectColumnQuery())
4881 ->setViewer(PhabricatorUser
::getOmnipotentUser())
4882 ->withProxyPHIDs($removed_phids)
4884 if (!$proxy_columns) {
4888 $proxy_phids = mpull($proxy_columns, 'getPHID');
4890 $position_table = new PhabricatorProjectColumnPosition();
4891 $conn_w = $position_table->establishConnection('w');
4895 'DELETE FROM %T WHERE objectPHID = %s AND columnPHID IN (%Ls)',
4896 $position_table->getTableName(),
4901 private function getModularTransactionTypes() {
4902 if ($this->modularTypes
=== null) {
4903 $template = $this->object->getApplicationTransactionTemplate();
4904 if ($template instanceof PhabricatorModularTransaction
) {
4905 $xtypes = $template->newModularTransactionTypes();
4906 foreach ($xtypes as $key => $xtype) {
4907 $xtype = clone $xtype;
4908 $xtype->setEditor($this);
4909 $xtypes[$key] = $xtype;
4915 $this->modularTypes
= $xtypes;
4918 return $this->modularTypes
;
4921 private function getModularTransactionType($type) {
4922 $types = $this->getModularTransactionTypes();
4923 return idx($types, $type);
4926 public function getCreateObjectTitle($author, $object) {
4927 return pht('%s created this object.', $author);
4930 public function getCreateObjectTitleForFeed($author, $object) {
4931 return pht('%s created an object: %s.', $author, $object);
4934 /* -( Queue )-------------------------------------------------------------- */
4936 protected function queueTransaction(
4937 PhabricatorApplicationTransaction
$xaction) {
4938 $this->transactionQueue
[] = $xaction;
4942 private function flushTransactionQueue($object) {
4943 if (!$this->transactionQueue
) {
4947 $xactions = $this->transactionQueue
;
4948 $this->transactionQueue
= array();
4950 $editor = $this->newEditorCopy();
4952 return $editor->applyTransactions($object, $xactions);
4955 final protected function newSubEditor(
4956 PhabricatorApplicationTransactionEditor
$template = null) {
4957 $editor = $this->newEditorCopy($template);
4959 $editor->parentEditor
= $this;
4960 $this->subEditors
[] = $editor;
4965 private function newEditorCopy(
4966 PhabricatorApplicationTransactionEditor
$template = null) {
4967 if ($template === null) {
4968 $template = newv(get_class($this), array());
4971 $editor = id(clone $template)
4972 ->setActor($this->getActor())
4973 ->setContentSource($this->getContentSource())
4974 ->setContinueOnNoEffect($this->getContinueOnNoEffect())
4975 ->setContinueOnMissingFields($this->getContinueOnMissingFields())
4976 ->setParentMessageID($this->getParentMessageID())
4977 ->setIsSilent($this->getIsSilent());
4979 if ($this->actingAsPHID
!== null) {
4980 $editor->setActingAsPHID($this->actingAsPHID
);
4983 $editor->mustEncrypt
= $this->mustEncrypt
;
4984 $editor->transactionGroupID
= $this->getTransactionGroupID();
4990 /* -( Stamps )------------------------------------------------------------- */
4993 public function newMailStampTemplates($object) {
4994 $actor = $this->getActor();
4996 $templates = array();
4998 $extensions = $this->newMailExtensions($object);
4999 foreach ($extensions as $extension) {
5000 $stamps = $extension->newMailStampTemplates($object);
5001 foreach ($stamps as $stamp) {
5002 $key = $stamp->getKey();
5003 if (isset($templates[$key])) {
5004 throw new Exception(
5006 'Mail extension ("%s") defines a stamp template with the '.
5007 'same key ("%s") as another template. Each stamp template '.
5008 'must have a unique key.',
5009 get_class($extension),
5013 $stamp->setViewer($actor);
5015 $templates[$key] = $stamp;
5022 final public function getMailStamp($key) {
5023 if (!isset($this->stampTemplates
)) {
5024 throw new PhutilInvalidStateException('newMailStampTemplates');
5027 if (!isset($this->stampTemplates
[$key])) {
5028 throw new Exception(
5030 'Editor ("%s") has no mail stamp template with provided key ("%s").',
5035 return $this->stampTemplates
[$key];
5038 private function newMailStamps($object, array $xactions) {
5039 $actor = $this->getActor();
5041 $this->stampTemplates
= $this->newMailStampTemplates($object);
5043 $extensions = $this->newMailExtensions($object);
5045 foreach ($extensions as $extension) {
5046 $extension->newMailStamps($object, $xactions);
5049 return $this->stampTemplates
;
5052 private function newMailExtensions($object) {
5053 $actor = $this->getActor();
5055 $all_extensions = PhabricatorMailEngineExtension
::getAllExtensions();
5057 $extensions = array();
5058 foreach ($all_extensions as $key => $template) {
5059 $extension = id(clone $template)
5063 if ($extension->supportsObject($object)) {
5064 $extensions[$key] = $extension;
5071 protected function newAuxiliaryMail($object, array $xactions) {
5075 private function generateMailStamps($object, $data) {
5076 if (!$data ||
!is_array($data)) {
5080 $templates = $this->newMailStampTemplates($object);
5081 foreach ($data as $spec) {
5082 if (!is_array($spec)) {
5086 $key = idx($spec, 'key');
5087 if (!isset($templates[$key])) {
5091 $type = idx($spec, 'type');
5092 if ($templates[$key]->getStampType() !== $type) {
5096 $value = idx($spec, 'value');
5097 $templates[$key]->setValueFromDictionary($value);
5101 foreach ($templates as $template) {
5102 $value = $template->getValueForRendering();
5104 $rendered = $template->renderStamps($value);
5105 if ($rendered === null) {
5109 $rendered = (array)$rendered;
5110 foreach ($rendered as $stamp) {
5111 $results[] = $stamp;
5115 natcasesort($results);
5120 public function getRemovedRecipientPHIDs() {
5121 return $this->mailRemovedPHIDs
;
5124 private function buildOldRecipientLists($object, $xactions) {
5125 // See T4776. Before we start making any changes, build a list of the old
5126 // recipients. If a change removes a user from the recipient list for an
5127 // object we still want to notify the user about that change. This allows
5128 // them to respond if they didn't want to be removed.
5130 if (!$this->shouldSendMail($object, $xactions)) {
5134 $this->oldTo
= $this->getMailTo($object);
5135 $this->oldCC
= $this->getMailCC($object);
5140 private function applyOldRecipientLists() {
5141 $actor_phid = $this->getActingAsPHID();
5143 // If you took yourself off the recipient list (for example, by
5144 // unsubscribing or resigning) assume that you know what you did and
5145 // don't need to be notified.
5147 // If you just moved from "To" to "Cc" (or vice versa), you're still a
5148 // recipient so we don't need to add you back in.
5150 $map = array_fuse($this->mailToPHIDs
) +
array_fuse($this->mailCCPHIDs
);
5152 foreach ($this->oldTo
as $phid) {
5153 if ($phid === $actor_phid) {
5157 if (isset($map[$phid])) {
5161 $this->mailToPHIDs
[] = $phid;
5162 $this->mailRemovedPHIDs
[] = $phid;
5165 foreach ($this->oldCC
as $phid) {
5166 if ($phid === $actor_phid) {
5170 if (isset($map[$phid])) {
5174 $this->mailCCPHIDs
[] = $phid;
5175 $this->mailRemovedPHIDs
[] = $phid;
5181 private function queueWebhooks($object, array $xactions) {
5182 $hook_viewer = PhabricatorUser
::getOmnipotentUser();
5184 $webhook_map = $this->webhookMap
;
5185 if (!is_array($webhook_map)) {
5186 $webhook_map = array();
5189 // Add any "Firehose" hooks to the list of hooks we're going to call.
5190 $firehose_hooks = id(new HeraldWebhookQuery())
5191 ->setViewer($hook_viewer)
5194 HeraldWebhook
::HOOKSTATUS_FIREHOSE
,
5197 foreach ($firehose_hooks as $firehose_hook) {
5198 // This is "the hook itself is the reason this hook is being called",
5199 // since we're including it because it's configured as a firehose
5201 $hook_phid = $firehose_hook->getPHID();
5202 $webhook_map[$hook_phid][] = $hook_phid;
5205 if (!$webhook_map) {
5209 // NOTE: We're going to queue calls to disabled webhooks, they'll just
5210 // immediately fail in the worker queue. This makes the behavior more
5213 $call_hooks = id(new HeraldWebhookQuery())
5214 ->setViewer($hook_viewer)
5215 ->withPHIDs(array_keys($webhook_map))
5218 foreach ($call_hooks as $call_hook) {
5219 $trigger_phids = idx($webhook_map, $call_hook->getPHID());
5221 $request = HeraldWebhookRequest
::initializeNewWebhookRequest($call_hook)
5222 ->setObjectPHID($object->getPHID())
5223 ->setTransactionPHIDs(mpull($xactions, 'getPHID'))
5224 ->setTriggerPHIDs($trigger_phids)
5225 ->setRetryMode(HeraldWebhookRequest
::RETRY_FOREVER
)
5226 ->setIsSilentAction((bool)$this->getIsSilent())
5227 ->setIsSecureAction((bool)$this->getMustEncrypt())
5230 $request->queueCall();
5234 private function hasWarnings($object, $xaction) {
5235 // TODO: For the moment, this is a very un-modular hack to support
5236 // a small number of warnings related to draft revisions. See PHI433.
5238 if (!($object instanceof DifferentialRevision
)) {
5242 $type = $xaction->getTransactionType();
5244 // TODO: This doesn't warn for inlines in Audit, even though they have
5245 // the same overall workflow.
5246 if ($type === DifferentialTransaction
::TYPE_INLINE
) {
5247 return (bool)$xaction->getComment()->getAttribute('editing', false);
5250 if (!$object->isDraft()) {
5254 if ($type != PhabricatorTransactions
::TYPE_SUBSCRIBERS
) {
5258 // We're only going to raise a warning if the transaction adds subscribers
5259 // other than the acting user. (This implementation is clumsy because the
5260 // code runs before a lot of normalization occurs.)
5262 $old = $this->getTransactionOldValue($object, $xaction);
5263 $new = $this->getPHIDTransactionNewValue($xaction, $old);
5264 $old = array_fuse($old);
5265 $new = array_fuse($new);
5266 $add = array_diff_key($new, $old);
5268 unset($add[$this->getActingAsPHID()]);
5277 private function buildHistoryMail(PhabricatorLiskDAO
$object) {
5278 $viewer = $this->requireActor();
5279 $recipient_phid = $this->getActingAsPHID();
5281 // Load every transaction so we can build a mail message with a complete
5282 // history for the object.
5283 $query = PhabricatorApplicationTransactionQuery
::newQueryForObject($object);
5285 ->setViewer($viewer)
5286 ->withObjectPHIDs(array($object->getPHID()))
5288 $xactions = array_reverse($xactions);
5290 $mail_messages = $this->buildMailWithRecipients(
5293 array($recipient_phid),
5296 $mail = head($mail_messages);
5298 // Since the user explicitly requested "!history", force delivery of this
5299 // message regardless of their other mail settings.
5300 $mail->setForceDelivery(true);
5305 public function newAutomaticInlineTransactions(
5306 PhabricatorLiskDAO
$object,
5308 PhabricatorCursorPagedPolicyAwareQuery
$query_template) {
5310 $actor = $this->getActor();
5312 $inlines = id(clone $query_template)
5314 ->withObjectPHIDs(array($object->getPHID()))
5315 ->withPublishableComments(true)
5316 ->needAppliedDrafts(true)
5317 ->needReplyToComments(true)
5319 $inlines = msort($inlines, 'getID');
5321 $xactions = array();
5323 foreach ($inlines as $key => $inline) {
5324 $xactions[] = $object->getApplicationTransactionTemplate()
5325 ->setTransactionType($transaction_type)
5326 ->attachComment($inline);
5329 $state_xaction = $this->newInlineStateTransaction(
5333 if ($state_xaction) {
5334 $xactions[] = $state_xaction;
5340 protected function newInlineStateTransaction(
5341 PhabricatorLiskDAO
$object,
5342 PhabricatorCursorPagedPolicyAwareQuery
$query_template) {
5344 $actor_phid = $this->getActingAsPHID();
5345 $author_phid = $object->getAuthorPHID();
5346 $actor_is_author = ($actor_phid == $author_phid);
5348 $state_map = PhabricatorTransactions
::getInlineStateMap();
5350 $inline_query = id(clone $query_template)
5351 ->setViewer($this->getActor())
5352 ->withObjectPHIDs(array($object->getPHID()))
5353 ->withFixedStates(array_keys($state_map))
5354 ->withPublishableComments(true);
5356 if ($actor_is_author) {
5357 $inline_query->withPublishedComments(true);
5360 $inlines = $inline_query->execute();
5366 $old_value = mpull($inlines, 'getFixedState', 'getPHID');
5367 $new_value = array();
5368 foreach ($old_value as $key => $state) {
5369 $new_value[$key] = $state_map[$state];
5372 // See PHI995. Copy some information about the inlines into the transaction
5373 // so we can tailor rendering behavior. In particular, we don't want to
5374 // render transactions about users marking their own inlines as "Done".
5376 $inline_details = array();
5377 foreach ($inlines as $inline) {
5378 $inline_details[$inline->getPHID()] = array(
5379 'authorPHID' => $inline->getAuthorPHID(),
5383 return $object->getApplicationTransactionTemplate()
5384 ->setTransactionType(PhabricatorTransactions
::TYPE_INLINESTATE
)
5385 ->setIgnoreOnNoEffect(true)
5386 ->setMetadataValue('inline.details', $inline_details)
5387 ->setOldValue($old_value)
5388 ->setNewValue($new_value);
5391 private function requireMFA(PhabricatorLiskDAO
$object, array $xactions) {
5392 $actor = $this->getActor();
5394 // Let omnipotent editors skip MFA. This is mostly aimed at scripts.
5395 if ($actor->isOmnipotent()) {
5399 $editor_class = get_class($this);
5401 $object_phid = $object->getPHID();
5403 $workflow_key = sprintf(
5404 'editor(%s).phid(%s)',
5408 $workflow_key = sprintf(
5413 $request = $this->getRequest();
5414 if ($request === null) {
5415 $source_type = $this->getContentSource()->getSourceTypeConstant();
5416 $conduit_type = PhabricatorConduitContentSource
::SOURCECONST
;
5417 $is_conduit = ($source_type === $conduit_type);
5419 throw new Exception(
5421 'This transaction group requires MFA to apply, but you can not '.
5422 'provide an MFA response via Conduit. Edit this object via the '.
5425 throw new Exception(
5427 'This transaction group requires MFA to apply, but the Editor was '.
5428 'not configured with a Request. This workflow can not perform an '.
5433 $cancel_uri = $this->getCancelURI();
5434 if ($cancel_uri === null) {
5435 throw new Exception(
5437 'This transaction group requires MFA to apply, but the Editor was '.
5438 'not configured with a Cancel URI. This workflow can not perform '.
5442 $token = id(new PhabricatorAuthSessionEngine())
5443 ->setWorkflowKey($workflow_key)
5444 ->requireHighSecurityToken($actor, $request, $cancel_uri);
5446 if (!$token->getIsUnchallengedToken()) {
5447 foreach ($xactions as $xaction) {
5448 $xaction->setIsMFATransaction(true);
5453 private function newMFATransactions(
5454 PhabricatorLiskDAO
$object,
5457 $has_engine = ($object instanceof PhabricatorEditEngineMFAInterface
);
5459 $engine = PhabricatorEditEngineMFAEngine
::newEngineForObject($object)
5460 ->setViewer($this->getActor());
5461 $require_mfa = $engine->shouldRequireMFA();
5462 $try_mfa = $engine->shouldTryMFA();
5464 $require_mfa = false;
5468 // If the user is mentioning an MFA object on another object or creating
5469 // a relationship like "parent" or "child" to this object, we always
5470 // allow the edit to move forward without requiring MFA.
5471 if ($this->getIsInverseEdgeEditor()) {
5475 if (!$require_mfa) {
5476 // If the object hasn't already opted into MFA, see if any of the
5477 // transactions want it.
5479 foreach ($xactions as $xaction) {
5480 $type = $xaction->getTransactionType();
5482 $xtype = $this->getModularTransactionType($type);
5484 $xtype = clone $xtype;
5485 $xtype->setStorage($xaction);
5486 if ($xtype->shouldTryMFA($object, $xaction)) {
5495 $this->setShouldRequireMFA(true);
5501 $type_mfa = PhabricatorTransactions
::TYPE_MFA
;
5504 foreach ($xactions as $xaction) {
5505 if ($xaction->getTransactionType() === $type_mfa) {
5515 $template = $object->getApplicationTransactionTemplate();
5517 $mfa_xaction = id(clone $template)
5518 ->setTransactionType($type_mfa)
5519 ->setNewValue(true);
5521 array_unshift($xactions, $mfa_xaction);
5526 private function getTitleForTextMail(
5527 PhabricatorApplicationTransaction
$xaction) {
5528 $type = $xaction->getTransactionType();
5530 $xtype = $this->getModularTransactionType($type);
5532 $xtype = clone $xtype;
5533 $xtype->setStorage($xaction);
5534 $comment = $xtype->getTitleForTextMail();
5535 if ($comment !== false) {
5540 return $xaction->getTitleForTextMail();
5543 private function getTitleForHTMLMail(
5544 PhabricatorApplicationTransaction
$xaction) {
5545 $type = $xaction->getTransactionType();
5547 $xtype = $this->getModularTransactionType($type);
5549 $xtype = clone $xtype;
5550 $xtype->setStorage($xaction);
5551 $comment = $xtype->getTitleForHTMLMail();
5552 if ($comment !== false) {
5557 return $xaction->getTitleForHTMLMail();
5561 private function getBodyForTextMail(
5562 PhabricatorApplicationTransaction
$xaction) {
5563 $type = $xaction->getTransactionType();
5565 $xtype = $this->getModularTransactionType($type);
5567 $xtype = clone $xtype;
5568 $xtype->setStorage($xaction);
5569 $comment = $xtype->getBodyForTextMail();
5570 if ($comment !== false) {
5575 return $xaction->getBodyForMail();
5578 private function isLockOverrideTransaction(
5579 PhabricatorApplicationTransaction
$xaction) {
5581 // See PHI1209. When an object is locked, certain types of transactions
5582 // can still be applied without requiring a policy check, like subscribing
5583 // or unsubscribing. We don't want these transactions to show the "Lock
5584 // Override" icon in the transaction timeline.
5586 // We could test if a transaction did no direct policy checks, but it may
5587 // have done additional policy checks during validation, so this is not a
5588 // reliable test (and could cause false negatives, where edits which did
5589 // override a lock are not marked properly).
5591 // For now, do this in a narrow way and just check against a hard-coded
5592 // list of non-override transaction situations. Some day, this should
5593 // likely be modularized.
5596 // Inverse edge edits don't interact with locks.
5597 if ($this->getIsInverseEdgeEditor()) {
5601 // For now, all edits other than subscribes always override locks.
5602 $type = $xaction->getTransactionType();
5603 if ($type !== PhabricatorTransactions
::TYPE_SUBSCRIBERS
) {
5607 // Subscribes override locks if they affect any users other than the
5610 $acting_phid = $this->getActingAsPHID();
5612 $old = array_fuse($xaction->getOldValue());
5613 $new = array_fuse($xaction->getNewValue());
5614 $add = array_diff_key($new, $old);
5615 $rem = array_diff_key($old, $new);
5618 foreach ($all as $phid) {
5619 if ($phid !== $acting_phid) {
5628 /* -( Extensions )--------------------------------------------------------- */
5631 private function validateTransactionsWithExtensions(
5632 PhabricatorLiskDAO
$object,
5636 $extensions = $this->getEditorExtensions();
5637 foreach ($extensions as $extension) {
5638 $extension_errors = $extension
5639 ->setObject($object)
5640 ->validateTransactions($object, $xactions);
5642 assert_instances_of(
5644 'PhabricatorApplicationTransactionValidationError');
5646 $errors[] = $extension_errors;
5649 return array_mergev($errors);
5652 private function getEditorExtensions() {
5653 if ($this->extensions
=== null) {
5654 $this->extensions
= $this->newEditorExtensions();
5656 return $this->extensions
;
5659 private function newEditorExtensions() {
5660 $extensions = PhabricatorEditorExtension
::getAllExtensions();
5662 $actor = $this->getActor();
5663 $object = $this->object;
5664 foreach ($extensions as $key => $extension) {
5666 $extension = id(clone $extension)
5669 ->setObject($object);
5671 if (!$extension->supportsObject($this, $object)) {
5672 unset($extensions[$key]);
5676 $extensions[$key] = $extension;