3 final class ManiphestTransactionEditor
4 extends PhabricatorApplicationTransactionEditor
{
6 private $oldProjectPHIDs;
7 private $moreValidationErrors = array();
9 public function getEditorApplicationClass() {
10 return 'PhabricatorManiphestApplication';
13 public function getEditorObjectsDescription() {
14 return pht('Maniphest Tasks');
17 public function getTransactionTypes() {
18 $types = parent
::getTransactionTypes();
20 $types[] = PhabricatorTransactions
::TYPE_COMMENT
;
21 $types[] = PhabricatorTransactions
::TYPE_EDGE
;
22 $types[] = PhabricatorTransactions
::TYPE_COLUMNS
;
23 $types[] = PhabricatorTransactions
::TYPE_VIEW_POLICY
;
24 $types[] = PhabricatorTransactions
::TYPE_EDIT_POLICY
;
29 public function getCreateObjectTitle($author, $object) {
30 return pht('%s created this task.', $author);
33 public function getCreateObjectTitleForFeed($author, $object) {
34 return pht('%s created %s.', $author, $object);
37 protected function getCustomTransactionOldValue(
38 PhabricatorLiskDAO
$object,
39 PhabricatorApplicationTransaction
$xaction) {
41 switch ($xaction->getTransactionType()) {
42 case PhabricatorTransactions
::TYPE_COLUMNS
:
47 protected function getCustomTransactionNewValue(
48 PhabricatorLiskDAO
$object,
49 PhabricatorApplicationTransaction
$xaction) {
51 switch ($xaction->getTransactionType()) {
52 case PhabricatorTransactions
::TYPE_COLUMNS
:
53 return $xaction->getNewValue();
57 protected function transactionHasEffect(
58 PhabricatorLiskDAO
$object,
59 PhabricatorApplicationTransaction
$xaction) {
61 $old = $xaction->getOldValue();
62 $new = $xaction->getNewValue();
64 switch ($xaction->getTransactionType()) {
65 case PhabricatorTransactions
::TYPE_COLUMNS
:
69 return parent
::transactionHasEffect($object, $xaction);
72 protected function applyCustomInternalTransaction(
73 PhabricatorLiskDAO
$object,
74 PhabricatorApplicationTransaction
$xaction) {
76 switch ($xaction->getTransactionType()) {
77 case PhabricatorTransactions
::TYPE_COLUMNS
:
82 protected function applyCustomExternalTransaction(
83 PhabricatorLiskDAO
$object,
84 PhabricatorApplicationTransaction
$xaction) {
86 switch ($xaction->getTransactionType()) {
87 case PhabricatorTransactions
::TYPE_COLUMNS
:
88 foreach ($xaction->getNewValue() as $move) {
89 $this->applyBoardMove($object, $move);
95 protected function applyFinalEffects(
96 PhabricatorLiskDAO
$object,
99 // When we change the status of a task, update tasks this tasks blocks
100 // with a message to the effect of "alincoln resolved blocking task Txxx."
101 $unblock_xaction = null;
102 foreach ($xactions as $xaction) {
103 switch ($xaction->getTransactionType()) {
104 case ManiphestTaskStatusTransaction
::TRANSACTIONTYPE
:
105 $unblock_xaction = $xaction;
110 if ($unblock_xaction !== null) {
111 $blocked_phids = PhabricatorEdgeQuery
::loadDestinationPHIDs(
113 ManiphestTaskDependedOnByTaskEdgeType
::EDGECONST
);
114 if ($blocked_phids) {
115 // In theory we could apply these through policies, but that seems a
116 // little bit surprising. For now, use the actor's vision.
117 $blocked_tasks = id(new ManiphestTaskQuery())
118 ->setViewer($this->getActor())
119 ->withPHIDs($blocked_phids)
120 ->needSubscriberPHIDs(true)
121 ->needProjectPHIDs(true)
124 $old = $unblock_xaction->getOldValue();
125 $new = $unblock_xaction->getNewValue();
127 foreach ($blocked_tasks as $blocked_task) {
128 $parent_xaction = id(new ManiphestTransaction())
129 ->setTransactionType(
130 ManiphestTaskUnblockTransaction
::TRANSACTIONTYPE
)
131 ->setOldValue(array($object->getPHID() => $old))
132 ->setNewValue(array($object->getPHID() => $new));
134 if ($this->getIsNewObject()) {
135 $parent_xaction->setMetadataValue('blocker.new', true);
138 $this->newSubEditor()
139 ->setContinueOnNoEffect(true)
140 ->setContinueOnMissingFields(true)
141 ->applyTransactions($blocked_task, array($parent_xaction));
149 protected function shouldSendMail(
150 PhabricatorLiskDAO
$object,
155 protected function getMailSubjectPrefix() {
156 return pht('[Maniphest]');
159 protected function getMailThreadID(PhabricatorLiskDAO
$object) {
160 return 'maniphest-task-'.$object->getPHID();
163 protected function getMailTo(PhabricatorLiskDAO
$object) {
166 if ($object->getOwnerPHID()) {
167 $phids[] = $object->getOwnerPHID();
169 $phids[] = $this->getActingAsPHID();
174 public function getMailTagsMap() {
176 ManiphestTransaction
::MAILTAG_STATUS
=>
177 pht("A task's status changes."),
178 ManiphestTransaction
::MAILTAG_OWNER
=>
179 pht("A task's owner changes."),
180 ManiphestTransaction
::MAILTAG_PRIORITY
=>
181 pht("A task's priority changes."),
182 ManiphestTransaction
::MAILTAG_CC
=>
183 pht("A task's subscribers change."),
184 ManiphestTransaction
::MAILTAG_PROJECTS
=>
185 pht("A task's associated projects change."),
186 ManiphestTransaction
::MAILTAG_UNBLOCK
=>
187 pht("One of a task's subtasks changes status."),
188 ManiphestTransaction
::MAILTAG_COLUMN
=>
189 pht('A task is moved between columns on a workboard.'),
190 ManiphestTransaction
::MAILTAG_COMMENT
=>
191 pht('Someone comments on a task.'),
192 ManiphestTransaction
::MAILTAG_OTHER
=>
193 pht('Other task activity not listed above occurs.'),
197 protected function buildReplyHandler(PhabricatorLiskDAO
$object) {
198 return id(new ManiphestReplyHandler())
199 ->setMailReceiver($object);
202 protected function buildMailTemplate(PhabricatorLiskDAO
$object) {
203 $id = $object->getID();
204 $title = $object->getTitle();
206 return id(new PhabricatorMetaMTAMail())
207 ->setSubject("T{$id}: {$title}");
210 protected function getObjectLinkButtonLabelForMail(
211 PhabricatorLiskDAO
$object) {
212 return pht('View Task');
215 protected function buildMailBody(
216 PhabricatorLiskDAO
$object,
219 $body = parent
::buildMailBody($object, $xactions);
221 if ($this->getIsNewObject()) {
222 $body->addRemarkupSection(
223 pht('TASK DESCRIPTION'),
224 $object->getDescription());
227 $body->addLinkSection(
229 $this->getObjectLinkButtonURIForMail($object));
232 $board_phids = array();
233 $type_columns = PhabricatorTransactions
::TYPE_COLUMNS
;
234 foreach ($xactions as $xaction) {
235 if ($xaction->getTransactionType() == $type_columns) {
236 $moves = $xaction->getNewValue();
237 foreach ($moves as $move) {
238 $board_phids[] = $move['boardPHID'];
244 $projects = id(new PhabricatorProjectQuery())
245 ->setViewer($this->requireActor())
246 ->withPHIDs($board_phids)
249 foreach ($projects as $project) {
250 $body->addLinkSection(
252 PhabricatorEnv
::getProductionURI($project->getWorkboardURI()));
260 protected function shouldPublishFeedStory(
261 PhabricatorLiskDAO
$object,
266 protected function supportsSearch() {
270 protected function shouldApplyHeraldRules(
271 PhabricatorLiskDAO
$object,
276 protected function buildHeraldAdapter(
277 PhabricatorLiskDAO
$object,
280 return id(new HeraldManiphestTaskAdapter())
284 protected function adjustObjectForPolicyChecks(
285 PhabricatorLiskDAO
$object,
288 $copy = parent
::adjustObjectForPolicyChecks($object, $xactions);
289 foreach ($xactions as $xaction) {
290 switch ($xaction->getTransactionType()) {
291 case ManiphestTaskOwnerTransaction
::TRANSACTIONTYPE
:
292 $copy->setOwnerPHID($xaction->getNewValue());
302 protected function validateAllTransactions(
303 PhabricatorLiskDAO
$object,
306 $errors = parent
::validateAllTransactions($object, $xactions);
308 if ($this->moreValidationErrors
) {
309 $errors = array_merge($errors, $this->moreValidationErrors
);
312 foreach ($this->getLockValidationErrors($object, $xactions) as $error) {
319 protected function expandTransactions(
320 PhabricatorLiskDAO
$object,
323 $actor = $this->getActor();
324 $actor_phid = $actor->getPHID();
326 $results = parent
::expandTransactions($object, $xactions);
328 $is_unassigned = ($object->getOwnerPHID() === null);
331 foreach ($xactions as $xaction) {
332 if ($xaction->getTransactionType() ==
333 ManiphestTaskOwnerTransaction
::TRANSACTIONTYPE
) {
339 $is_open = !$object->isClosed();
342 foreach ($xactions as $xaction) {
343 switch ($xaction->getTransactionType()) {
344 case ManiphestTaskStatusTransaction
::TRANSACTIONTYPE
:
345 $new_status = $xaction->getNewValue();
350 if ($new_status === null) {
353 $is_closing = ManiphestTaskStatus
::isClosedStatus($new_status);
356 // If the task is not assigned, not being assigned, currently open, and
357 // being closed, try to assign the actor as the owner.
358 if ($is_unassigned && !$any_assign && $is_open && $is_closing) {
359 $is_claim = ManiphestTaskStatus
::isClaimStatus($new_status);
361 // Don't assign the actor if they aren't a real user.
362 // Don't claim the task if the status is configured to not claim.
363 if ($actor_phid && $is_claim) {
364 $results[] = id(new ManiphestTransaction())
365 ->setTransactionType(ManiphestTaskOwnerTransaction
::TRANSACTIONTYPE
)
366 ->setNewValue($actor_phid);
370 // Automatically subscribe the author when they create a task.
371 if ($this->getIsNewObject()) {
373 $results[] = id(new ManiphestTransaction())
374 ->setTransactionType(PhabricatorTransactions
::TYPE_SUBSCRIBERS
)
377 '+' => array($actor_phid => $actor_phid),
382 $send_notifications = PhabricatorNotificationClient
::isEnabled();
383 if ($send_notifications) {
384 $this->oldProjectPHIDs
= $this->loadProjectPHIDs($object);
390 protected function expandTransaction(
391 PhabricatorLiskDAO
$object,
392 PhabricatorApplicationTransaction
$xaction) {
394 $results = parent
::expandTransaction($object, $xaction);
396 $type = $xaction->getTransactionType();
398 case PhabricatorTransactions
::TYPE_COLUMNS
:
400 $more_xactions = $this->buildMoveTransaction($object, $xaction);
401 foreach ($more_xactions as $more_xaction) {
402 $results[] = $more_xaction;
404 } catch (Exception
$ex) {
405 $error = new PhabricatorApplicationTransactionValidationError(
410 $this->moreValidationErrors
[] = $error;
413 case ManiphestTaskOwnerTransaction
::TRANSACTIONTYPE
:
414 // If this is a no-op update, don't expand it.
415 $old_value = $object->getOwnerPHID();
416 $new_value = $xaction->getNewValue();
417 if ($old_value === $new_value) {
421 // When a task is reassigned, move the old owner to the subscriber
422 // list so they're still in the loop.
424 $results[] = id(new ManiphestTransaction())
425 ->setTransactionType(PhabricatorTransactions
::TYPE_SUBSCRIBERS
)
426 ->setIgnoreOnNoEffect(true)
429 '+' => array($old_value => $old_value),
438 private function buildMoveTransaction(
439 PhabricatorLiskDAO
$object,
440 PhabricatorApplicationTransaction
$xaction) {
441 $actor = $this->getActor();
443 $new = $xaction->getNewValue();
444 if (!is_array($new)) {
445 $this->validateColumnPHID($new);
449 $relative_phids = array();
450 foreach ($new as $key => $value) {
451 if (!is_array($value)) {
452 $this->validateColumnPHID($value);
454 'columnPHID' => $value,
458 PhutilTypeSpec
::checkMap(
461 'columnPHID' => 'string',
462 'beforePHIDs' => 'optional list<string>',
463 'afterPHIDs' => 'optional list<string>',
465 // Deprecated older variations of "beforePHIDs" and "afterPHIDs".
466 'beforePHID' => 'optional string',
467 'afterPHID' => 'optional string',
470 $value = $value +
array(
471 'beforePHIDs' => array(),
472 'afterPHIDs' => array(),
475 // Normalize the legacy keys "beforePHID" and "afterPHID" keys to the
477 if (!empty($value['afterPHID'])) {
478 if ($value['afterPHIDs']) {
481 'Transaction specifies both "afterPHID" and "afterPHIDs". '.
482 'Specify only "afterPHIDs".'));
484 $value['afterPHIDs'] = array($value['afterPHID']);
485 unset($value['afterPHID']);
488 if (isset($value['beforePHID'])) {
489 if ($value['beforePHIDs']) {
492 'Transaction specifies both "beforePHID" and "beforePHIDs". '.
493 'Specify only "beforePHIDs".'));
495 $value['beforePHIDs'] = array($value['beforePHID']);
496 unset($value['beforePHID']);
499 foreach ($value['beforePHIDs'] as $phid) {
500 $relative_phids[] = $phid;
503 foreach ($value['afterPHIDs'] as $phid) {
504 $relative_phids[] = $phid;
510 // We require that objects you specify in "beforePHIDs" or "afterPHIDs"
511 // are real objects which exist and which you have permission to view.
512 // If you provide other objects, we remove them from the specification.
514 if ($relative_phids) {
515 $objects = id(new PhabricatorObjectQuery())
517 ->withPHIDs($relative_phids)
519 $objects = mpull($objects, null, 'getPHID');
524 foreach ($new as $key => $value) {
525 $value['afterPHIDs'] = $this->filterValidPHIDs(
526 $value['afterPHIDs'],
528 $value['beforePHIDs'] = $this->filterValidPHIDs(
529 $value['beforePHIDs'],
535 $column_phids = ipull($new, 'columnPHID');
537 $columns = id(new PhabricatorProjectColumnQuery())
539 ->withPHIDs($column_phids)
541 $columns = mpull($columns, null, 'getPHID');
546 $board_phids = mpull($columns, 'getProjectPHID');
547 $object_phid = $object->getPHID();
549 // Note that we may not have an object PHID if we're creating a new
551 $object_phids = array();
553 $object_phids[] = $object_phid;
557 $layout_engine = id(new PhabricatorBoardLayoutEngine())
558 ->setViewer($this->getActor())
559 ->setBoardPHIDs($board_phids)
560 ->setObjectPHIDs($object_phids)
561 ->setFetchAllBoards(true)
565 foreach ($new as $key => $spec) {
566 $column_phid = $spec['columnPHID'];
567 $column = idx($columns, $column_phid);
571 'Column move transaction specifies column PHID "%s", but there '.
572 'is no corresponding column with this PHID.',
576 $board_phid = $column->getProjectPHID();
579 $old_columns = $layout_engine->getObjectColumns(
582 $old_column_phids = mpull($old_columns, 'getPHID');
584 $old_column_phids = array();
588 'boardPHID' => $board_phid,
589 'fromColumnPHIDs' => $old_column_phids,
592 // Check if the object is already in this column, and isn't being moved.
593 // We can just drop this column change if it has no effect.
594 $from_map = array_fuse($spec['fromColumnPHIDs']);
595 $already_here = isset($from_map[$column_phid]);
597 $is_reordering = ($spec['afterPHIDs'] ||
$spec['beforePHIDs']);
598 if ($already_here && !$is_reordering) {
605 $new = array_values($new);
606 $xaction->setNewValue($new);
611 // If we're moving the object into a column and it does not already belong
612 // in the column, add the appropriate board. For normal columns, this
613 // is the board PHID. For proxy columns, it is the proxy PHID, unless the
614 // object is already a member of some descendant of the proxy PHID.
616 // The major case where this can happen is moves via the API, but it also
617 // happens when a user drags a task from the "Backlog" to a milestone
621 $current_phids = PhabricatorEdgeQuery
::loadDestinationPHIDs(
623 PhabricatorProjectObjectHasProjectEdgeType
::EDGECONST
);
624 $current_phids = array_fuse($current_phids);
626 $current_phids = array();
629 $add_boards = array();
630 foreach ($new as $move) {
631 $column_phid = $move['columnPHID'];
632 $board_phid = $move['boardPHID'];
633 $column = $columns[$column_phid];
634 $proxy_phid = $column->getProxyPHID();
636 // If this is a normal column, add the board if the object isn't already
639 if (!isset($current_phids[$board_phid])) {
640 $add_boards[] = $board_phid;
645 // If this is a proxy column but the object is already associated with
646 // the proxy board, we don't need to do anything.
647 if (isset($current_phids[$proxy_phid])) {
651 // If this a proxy column and the object is already associated with some
652 // descendant of the proxy board, we also don't need to do anything.
653 $descendants = id(new PhabricatorProjectQuery())
654 ->setViewer(PhabricatorUser
::getOmnipotentUser())
655 ->withAncestorProjectPHIDs(array($proxy_phid))
658 $found_descendant = false;
659 foreach ($descendants as $descendant) {
660 if (isset($current_phids[$descendant->getPHID()])) {
661 $found_descendant = true;
666 if ($found_descendant) {
670 // Otherwise, we're moving the object to a proxy column which it is not
671 // a member of yet, so add an association to the column's proxy board.
673 $add_boards[] = $proxy_phid;
677 $more[] = id(new ManiphestTransaction())
678 ->setTransactionType(PhabricatorTransactions
::TYPE_EDGE
)
681 PhabricatorProjectObjectHasProjectEdgeType
::EDGECONST
)
682 ->setIgnoreOnNoEffect(true)
685 '+' => array_fuse($add_boards),
692 private function applyBoardMove($object, array $move) {
693 $board_phid = $move['boardPHID'];
694 $column_phid = $move['columnPHID'];
696 $before_phids = $move['beforePHIDs'];
697 $after_phids = $move['afterPHIDs'];
699 $object_phid = $object->getPHID();
701 // We're doing layout with the omnipotent viewer to make sure we don't
702 // remove positions in columns that exist, but which the actual actor
704 $omnipotent_viewer = PhabricatorUser
::getOmnipotentUser();
706 $select_phids = array($board_phid);
708 $descendants = id(new PhabricatorProjectQuery())
709 ->setViewer($omnipotent_viewer)
710 ->withAncestorProjectPHIDs($select_phids)
712 foreach ($descendants as $descendant) {
713 $select_phids[] = $descendant->getPHID();
716 $board_tasks = id(new ManiphestTaskQuery())
717 ->setViewer($omnipotent_viewer)
718 ->withEdgeLogicPHIDs(
719 PhabricatorProjectObjectHasProjectEdgeType
::EDGECONST
,
720 PhabricatorQueryConstraint
::OPERATOR_ANCESTOR
,
721 array($select_phids))
724 $board_tasks = mpull($board_tasks, null, 'getPHID');
725 $board_tasks[$object_phid] = $object;
727 // Make sure tasks are sorted by ID, so we lay out new positions in
729 $board_tasks = msort($board_tasks, 'getID');
731 $object_phids = array_keys($board_tasks);
733 $engine = id(new PhabricatorBoardLayoutEngine())
734 ->setViewer($omnipotent_viewer)
735 ->setBoardPHIDs(array($board_phid))
736 ->setObjectPHIDs($object_phids)
739 // TODO: This logic needs to be revised when we legitimately support
740 // multiple column positions.
741 $columns = $engine->getObjectColumns($board_phid, $object_phid);
742 foreach ($columns as $column) {
743 $engine->queueRemovePosition(
749 $engine->queueAddPosition(
756 $engine->applyPositionUpdates();
760 private function validateColumnPHID($value) {
761 if (phid_get_type($value) == PhabricatorProjectColumnPHIDType
::TYPECONST
) {
767 'When moving objects between columns on a board, columns must '.
768 'be identified by PHIDs. This transaction uses "%s" to identify '.
769 'a column, but that is not a valid column PHID.',
774 private function getLockValidationErrors($object, array $xactions) {
777 $old_owner = $object->getOwnerPHID();
778 $old_status = $object->getStatus();
780 $new_owner = $old_owner;
781 $new_status = $old_status;
783 $owner_xaction = null;
784 $status_xaction = null;
786 foreach ($xactions as $xaction) {
787 switch ($xaction->getTransactionType()) {
788 case ManiphestTaskOwnerTransaction
::TRANSACTIONTYPE
:
789 $new_owner = $xaction->getNewValue();
790 $owner_xaction = $xaction;
792 case ManiphestTaskStatusTransaction
::TRANSACTIONTYPE
:
793 $new_status = $xaction->getNewValue();
794 $status_xaction = $xaction;
799 $actor_phid = $this->getActingAsPHID();
801 $was_locked = ManiphestTaskStatus
::areEditsLockedInStatus(
803 $now_locked = ManiphestTaskStatus
::areEditsLockedInStatus(
807 // If we're not ending in an edit-locked status, everything is good.
808 } else if ($new_owner !== null) {
809 // If we ending the edit with some valid owner, this is allowed for
810 // now. We might need to revisit this.
812 // The edits end with the task locked and unowned. No one will be able
813 // to edit it, so we forbid this. We try to be specific about what the
816 $owner_changed = ($old_owner && !$new_owner);
817 $status_changed = ($was_locked !== $now_locked);
820 if ($status_changed && $owner_changed) {
822 'You can not lock this task and unassign it at the same time '.
823 'because no one will be able to edit it anymore. Lock the task '.
824 'or remove the owner, but not both.');
825 $problem_xaction = $status_xaction;
826 } else if ($status_changed) {
828 'You can not lock this task because it does not have an owner. '.
829 'No one would be able to edit the task. Assign the task to an '.
830 'owner before locking it.');
831 $problem_xaction = $status_xaction;
832 } else if ($owner_changed) {
834 'You can not remove the owner of this task because it is locked '.
835 'and no one would be able to edit the task. Reassign the task or '.
836 'unlock it before removing the owner.');
837 $problem_xaction = $owner_xaction;
839 // If the task was already broken, we don't have a transaction to
840 // complain about so just let it through. In theory, this is
841 // impossible since policy rules should kick in before we get here.
845 $errors[] = new PhabricatorApplicationTransactionValidationError(
846 $problem_xaction->getTransactionType(),
856 private function filterValidPHIDs($phid_list, array $object_map) {
857 foreach ($phid_list as $key => $phid) {
858 if (isset($object_map[$phid])) {
862 unset($phid_list[$key]);
865 return array_values($phid_list);
868 protected function didApplyTransactions($object, array $xactions) {
869 $send_notifications = PhabricatorNotificationClient
::isEnabled();
870 if ($send_notifications) {
871 $old_phids = $this->oldProjectPHIDs
;
872 $new_phids = $this->loadProjectPHIDs($object);
874 // We want to emit update notifications for all old and new tagged
875 // projects, and all parents of those projects. For example, if an
876 // edit removes project "A > B" from a task, the "A" workboard should
877 // receive an update event.
879 $project_phids = array_fuse($old_phids) +
array_fuse($new_phids);
880 $project_phids = array_keys($project_phids);
882 if ($project_phids) {
883 $projects = id(new PhabricatorProjectQuery())
884 ->setViewer(PhabricatorUser
::getOmnipotentUser())
885 ->withPHIDs($project_phids)
888 $notify_projects = array();
889 foreach ($projects as $project) {
890 $notify_projects[$project->getPHID()] = $project;
891 foreach ($project->getAncestorProjects() as $ancestor) {
892 $notify_projects[$ancestor->getPHID()] = $ancestor;
896 foreach ($notify_projects as $key => $project) {
897 if (!$project->getHasWorkboard()) {
898 unset($notify_projects[$key]);
902 $notify_phids = array_keys($notify_projects);
906 'type' => 'workboards',
907 'subscribers' => $notify_phids,
910 PhabricatorNotificationClient
::tryToPostMessage($data);
918 private function loadProjectPHIDs(ManiphestTask
$task) {
919 if (!$task->getPHID()) {
923 $edge_query = id(new PhabricatorEdgeQuery())
924 ->withSourcePHIDs(array($task->getPHID()))
927 PhabricatorProjectObjectHasProjectEdgeType
::EDGECONST
,
930 $edge_query->execute();
932 return $edge_query->getDestinationPHIDs();