diff --git a/src/applications/audit/editor/PhabricatorAuditEditor.php b/src/applications/audit/editor/PhabricatorAuditEditor.php index 3a6859549d..7fdb586f56 100644 --- a/src/applications/audit/editor/PhabricatorAuditEditor.php +++ b/src/applications/audit/editor/PhabricatorAuditEditor.php @@ -1,845 +1,846 @@ rawPatch = $patch; return $this; } public function getRawPatch() { return $this->rawPatch; } public function getEditorApplicationClass() { return 'PhabricatorDiffusionApplication'; } public function getEditorObjectsDescription() { return pht('Audits'); } public function getTransactionTypes() { $types = parent::getTransactionTypes(); $types[] = PhabricatorTransactions::TYPE_COMMENT; $types[] = PhabricatorTransactions::TYPE_EDGE; $types[] = PhabricatorTransactions::TYPE_INLINESTATE; $types[] = PhabricatorAuditTransaction::TYPE_COMMIT; // TODO: These will get modernized eventually, but that can happen one // at a time later on. $types[] = PhabricatorAuditActionConstants::INLINE; return $types; } protected function expandTransactions( PhabricatorLiskDAO $object, array $xactions) { foreach ($xactions as $xaction) { switch ($xaction->getTransactionType()) { case PhabricatorTransactions::TYPE_INLINESTATE: $this->didExpandInlineState = true; break; } } $this->oldAuditStatus = $object->getAuditStatus(); return parent::expandTransactions($object, $xactions); } protected function transactionHasEffect( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case PhabricatorAuditActionConstants::INLINE: return $xaction->hasComment(); } return parent::transactionHasEffect($object, $xaction); } protected function getCustomTransactionOldValue( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case PhabricatorAuditActionConstants::INLINE: case PhabricatorAuditTransaction::TYPE_COMMIT: return null; } return parent::getCustomTransactionOldValue($object, $xaction); } protected function getCustomTransactionNewValue( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case PhabricatorAuditActionConstants::INLINE: case PhabricatorAuditTransaction::TYPE_COMMIT: return $xaction->getNewValue(); } return parent::getCustomTransactionNewValue($object, $xaction); } protected function applyCustomInternalTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case PhabricatorAuditActionConstants::INLINE: case PhabricatorAuditTransaction::TYPE_COMMIT: return; } return parent::applyCustomInternalTransaction($object, $xaction); } protected function applyCustomExternalTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case PhabricatorAuditTransaction::TYPE_COMMIT: return; case PhabricatorAuditActionConstants::INLINE: $reply = $xaction->getComment()->getReplyToComment(); if ($reply && !$reply->getHasReplies()) { $reply->setHasReplies(1)->save(); } return; } return parent::applyCustomExternalTransaction($object, $xaction); } protected function applyBuiltinExternalTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case PhabricatorTransactions::TYPE_INLINESTATE: $table = new PhabricatorAuditTransactionComment(); $conn_w = $table->establishConnection('w'); foreach ($xaction->getNewValue() as $phid => $state) { queryfx( $conn_w, 'UPDATE %T SET fixedState = %s WHERE phid = %s', $table->getTableName(), $state, $phid); } break; } return parent::applyBuiltinExternalTransaction($object, $xaction); } protected function applyFinalEffects( PhabricatorLiskDAO $object, array $xactions) { // Load auditors explicitly; we may not have them if the caller was a // generic piece of infrastructure. $commit = id(new DiffusionCommitQuery()) ->setViewer($this->requireActor()) ->withIDs(array($object->getID())) ->needAuditRequests(true) ->executeOne(); if (!$commit) { throw new Exception( pht('Failed to load commit during transaction finalization!')); } $object->attachAudits($commit->getAudits()); $status_concerned = PhabricatorAuditStatusConstants::CONCERNED; $status_closed = PhabricatorAuditStatusConstants::CLOSED; $status_resigned = PhabricatorAuditStatusConstants::RESIGNED; $status_accepted = PhabricatorAuditStatusConstants::ACCEPTED; $status_concerned = PhabricatorAuditStatusConstants::CONCERNED; $actor_phid = $this->getActingAsPHID(); $actor_is_author = ($object->getAuthorPHID()) && ($actor_phid == $object->getAuthorPHID()); $import_status_flag = null; foreach ($xactions as $xaction) { switch ($xaction->getTransactionType()) { case PhabricatorAuditTransaction::TYPE_COMMIT: $import_status_flag = PhabricatorRepositoryCommit::IMPORTED_PUBLISH; break; } } $old_status = $this->oldAuditStatus; $requests = $object->getAudits(); $object->updateAuditStatus($requests); $new_status = $object->getAuditStatus(); $object->save(); if ($import_status_flag) { $object->writeImportStatusFlag($import_status_flag); } // If the commit has changed state after this edit, add an informational // transaction about the state change. if ($old_status != $new_status) { if ($object->isAuditStatusPartiallyAudited()) { // This state isn't interesting enough to get a transaction. The // best way we could lead the user forward is something like "This // commit still requires additional audits." but that's redundant and // probably not very useful. } else { $xaction = $object->getApplicationTransactionTemplate() ->setTransactionType(DiffusionCommitStateTransaction::TRANSACTIONTYPE) ->setOldValue($old_status) ->setNewValue($new_status); $xaction = $this->populateTransaction($object, $xaction); $xaction->save(); } } // Collect auditor PHIDs for building mail. $this->auditorPHIDs = mpull($object->getAudits(), 'getAuditorPHID'); return $xactions; } protected function expandTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { $xactions = parent::expandTransaction($object, $xaction); switch ($xaction->getTransactionType()) { case PhabricatorAuditTransaction::TYPE_COMMIT: $request = $this->createAuditRequestTransactionFromCommitMessage( $object); if ($request) { $xactions[] = $request; $this->addUnmentionablePHIDs($request->getNewValue()); } break; default: break; } if (!$this->didExpandInlineState) { switch ($xaction->getTransactionType()) { case PhabricatorTransactions::TYPE_COMMENT: $this->didExpandInlineState = true; $query_template = id(new DiffusionDiffInlineCommentQuery()) ->withCommitPHIDs(array($object->getPHID())); $state_xaction = $this->newInlineStateTransaction( $object, $query_template); if ($state_xaction) { $xactions[] = $state_xaction; } break; } } return $xactions; } private function createAuditRequestTransactionFromCommitMessage( PhabricatorRepositoryCommit $commit) { $actor = $this->getActor(); $data = $commit->getCommitData(); $message = $data->getCommitMessage(); $result = DifferentialCommitMessageParser::newStandardParser($actor) ->setRaiseMissingFieldErrors(false) ->parseFields($message); $field_key = DifferentialAuditorsCommitMessageField::FIELDKEY; $phids = idx($result, $field_key, null); if (!$phids) { return array(); } // If a commit lists its author as an auditor, just pretend it does not. foreach ($phids as $key => $phid) { if ($phid == $commit->getAuthorPHID()) { unset($phids[$key]); } } if (!$phids) { return array(); } return $commit->getApplicationTransactionTemplate() ->setTransactionType(DiffusionCommitAuditorsTransaction::TRANSACTIONTYPE) ->setNewValue( array( '+' => array_fuse($phids), )); } protected function sortTransactions(array $xactions) { $xactions = parent::sortTransactions($xactions); $head = array(); $tail = array(); foreach ($xactions as $xaction) { $type = $xaction->getTransactionType(); if ($type == PhabricatorAuditActionConstants::INLINE) { $tail[] = $xaction; } else { $head[] = $xaction; } } return array_values(array_merge($head, $tail)); } protected function supportsSearch() { return true; } protected function expandCustomRemarkupBlockTransactions( PhabricatorLiskDAO $object, array $xactions, array $changes, PhutilMarkupEngine $engine) { $actor = $this->getActor(); $result = array(); // Some interactions (like "Fixes Txxx" interacting with Maniphest) have // already been processed, so we're only re-parsing them here to avoid // generating an extra redundant mention. Other interactions are being // processed for the first time. // We're only recognizing magic in the commit message itself, not in // audit comments. $is_commit = false; foreach ($xactions as $xaction) { switch ($xaction->getTransactionType()) { case PhabricatorAuditTransaction::TYPE_COMMIT: $is_commit = true; break; } } if (!$is_commit) { return $result; } $flat_blocks = mpull($changes, 'getNewValue'); $huge_block = implode("\n\n", $flat_blocks); $phid_map = array(); $monograms = array(); $task_refs = id(new ManiphestCustomFieldStatusParser()) ->parseCorpus($huge_block); foreach ($task_refs as $match) { foreach ($match['monograms'] as $monogram) { $monograms[] = $monogram; } } $rev_refs = id(new DifferentialCustomFieldDependsOnParser()) ->parseCorpus($huge_block); foreach ($rev_refs as $match) { foreach ($match['monograms'] as $monogram) { $monograms[] = $monogram; } } $objects = id(new PhabricatorObjectQuery()) ->setViewer($this->getActor()) ->withNames($monograms) ->execute(); $phid_map[] = mpull($objects, 'getPHID', 'getPHID'); $reverts_refs = id(new DifferentialCustomFieldRevertsParser()) ->parseCorpus($huge_block); $reverts = array_mergev(ipull($reverts_refs, 'monograms')); if ($reverts) { $reverted_objects = DiffusionCommitRevisionQuery::loadRevertedObjects( $actor, $object, $reverts, $object->getRepository()); $reverted_phids = mpull($reverted_objects, 'getPHID', 'getPHID'); $reverts_edge = DiffusionCommitRevertsCommitEdgeType::EDGECONST; $result[] = id(new PhabricatorAuditTransaction()) ->setTransactionType(PhabricatorTransactions::TYPE_EDGE) ->setMetadataValue('edge:type', $reverts_edge) ->setNewValue(array('+' => $reverted_phids)); $phid_map[] = $reverted_phids; } $phid_map = array_mergev($phid_map); $this->addUnmentionablePHIDs($phid_map); return $result; } protected function buildReplyHandler(PhabricatorLiskDAO $object) { $reply_handler = new PhabricatorAuditReplyHandler(); $reply_handler->setMailReceiver($object); return $reply_handler; } protected function getMailSubjectPrefix() { return pht('[Diffusion]'); } protected function getMailThreadID(PhabricatorLiskDAO $object) { // For backward compatibility, use this legacy thread ID. return 'diffusion-audit-'.$object->getPHID(); } protected function buildMailTemplate(PhabricatorLiskDAO $object) { $identifier = $object->getCommitIdentifier(); $repository = $object->getRepository(); $summary = $object->getSummary(); $name = $repository->formatCommitName($identifier); $subject = "{$name}: {$summary}"; $template = id(new PhabricatorMetaMTAMail()) ->setSubject($subject); $this->attachPatch( $template, $object); return $template; } protected function getMailTo(PhabricatorLiskDAO $object) { $this->requireAuditors($object); $phids = array(); if ($object->getAuthorPHID()) { $phids[] = $object->getAuthorPHID(); } foreach ($object->getAudits() as $audit) { if (!$audit->isInteresting()) { // Don't send mail to uninteresting auditors, like packages which // own this code but which audits have not triggered for. continue; } if (!$audit->isResigned()) { $phids[] = $audit->getAuditorPHID(); } } $phids[] = $this->getActingAsPHID(); return $phids; } protected function newMailUnexpandablePHIDs(PhabricatorLiskDAO $object) { $this->requireAuditors($object); $phids = array(); foreach ($object->getAudits() as $auditor) { if ($auditor->isResigned()) { $phids[] = $auditor->getAuditorPHID(); } } return $phids; } - protected function getObjectLinkButtonLabelForMail() { + protected function getObjectLinkButtonLabelForMail( + PhabricatorLiskDAO $object) { return pht('View Commit'); } protected function buildMailBody( PhabricatorLiskDAO $object, array $xactions) { $body = parent::buildMailBody($object, $xactions); $type_inline = PhabricatorAuditActionConstants::INLINE; $type_push = PhabricatorAuditTransaction::TYPE_COMMIT; $is_commit = false; $inlines = array(); foreach ($xactions as $xaction) { if ($xaction->getTransactionType() == $type_inline) { $inlines[] = $xaction; } if ($xaction->getTransactionType() == $type_push) { $is_commit = true; } } if ($inlines) { $body->addTextSection( pht('INLINE COMMENTS'), $this->renderInlineCommentsForMail($object, $inlines)); } if ($is_commit) { $data = $object->getCommitData(); $body->addTextSection(pht('AFFECTED FILES'), $this->affectedFiles); $this->inlinePatch( $body, $object); } $data = $object->getCommitData(); $user_phids = array(); $author_phid = $object->getAuthorPHID(); if ($author_phid) { $user_phids[$author_phid][] = pht('Author'); } $committer_phid = $data->getCommitDetail('committerPHID'); if ($committer_phid && ($committer_phid != $author_phid)) { $user_phids[$committer_phid][] = pht('Committer'); } foreach ($this->auditorPHIDs as $auditor_phid) { $user_phids[$auditor_phid][] = pht('Auditor'); } // TODO: It would be nice to show pusher here too, but that information // is a little tricky to get at right now. if ($user_phids) { $handle_phids = array_keys($user_phids); $handles = id(new PhabricatorHandleQuery()) ->setViewer($this->requireActor()) ->withPHIDs($handle_phids) ->execute(); $user_info = array(); foreach ($user_phids as $phid => $roles) { $user_info[] = pht( '%s (%s)', $handles[$phid]->getName(), implode(', ', $roles)); } $body->addTextSection( pht('USERS'), implode("\n", $user_info)); } $monogram = $object->getRepository()->formatCommitName( $object->getCommitIdentifier()); $body->addLinkSection( pht('COMMIT'), PhabricatorEnv::getProductionURI('/'.$monogram)); return $body; } private function attachPatch( PhabricatorMetaMTAMail $template, PhabricatorRepositoryCommit $commit) { if (!$this->getRawPatch()) { return; } $attach_key = 'metamta.diffusion.attach-patches'; $attach_patches = PhabricatorEnv::getEnvConfig($attach_key); if (!$attach_patches) { return; } $repository = $commit->getRepository(); $encoding = $repository->getDetail('encoding', 'UTF-8'); $raw_patch = $this->getRawPatch(); $commit_name = $repository->formatCommitName( $commit->getCommitIdentifier()); $template->addAttachment( new PhabricatorMailAttachment( $raw_patch, $commit_name.'.patch', 'text/x-patch; charset='.$encoding)); } private function inlinePatch( PhabricatorMetaMTAMailBody $body, PhabricatorRepositoryCommit $commit) { if (!$this->getRawPatch()) { return; } $inline_key = 'metamta.diffusion.inline-patches'; $inline_patches = PhabricatorEnv::getEnvConfig($inline_key); if (!$inline_patches) { return; } $repository = $commit->getRepository(); $raw_patch = $this->getRawPatch(); $result = null; $len = substr_count($raw_patch, "\n"); if ($len <= $inline_patches) { // We send email as utf8, so we need to convert the text to utf8 if // we can. $encoding = $repository->getDetail('encoding', 'UTF-8'); if ($encoding) { $raw_patch = phutil_utf8_convert($raw_patch, 'UTF-8', $encoding); } $result = phutil_utf8ize($raw_patch); } if ($result) { $result = "PATCH\n\n{$result}\n"; } $body->addRawSection($result); } private function renderInlineCommentsForMail( PhabricatorLiskDAO $object, array $inline_xactions) { $inlines = mpull($inline_xactions, 'getComment'); $block = array(); $path_map = id(new DiffusionPathQuery()) ->withPathIDs(mpull($inlines, 'getPathID')) ->execute(); $path_map = ipull($path_map, 'path', 'id'); foreach ($inlines as $inline) { $path = idx($path_map, $inline->getPathID()); if ($path === null) { continue; } $start = $inline->getLineNumber(); $len = $inline->getLineLength(); if ($len) { $range = $start.'-'.($start + $len); } else { $range = $start; } $content = $inline->getContent(); $block[] = "{$path}:{$range} {$content}"; } return implode("\n", $block); } public function getMailTagsMap() { return array( PhabricatorAuditTransaction::MAILTAG_COMMIT => pht('A commit is created.'), PhabricatorAuditTransaction::MAILTAG_ACTION_CONCERN => pht('A commit has a concerned raised against it.'), PhabricatorAuditTransaction::MAILTAG_ACTION_ACCEPT => pht('A commit is accepted.'), PhabricatorAuditTransaction::MAILTAG_ACTION_RESIGN => pht('A commit has an auditor resign.'), PhabricatorAuditTransaction::MAILTAG_ACTION_CLOSE => pht('A commit is closed.'), PhabricatorAuditTransaction::MAILTAG_ADD_AUDITORS => pht('A commit has auditors added.'), PhabricatorAuditTransaction::MAILTAG_ADD_CCS => pht("A commit's subscribers change."), PhabricatorAuditTransaction::MAILTAG_PROJECTS => pht("A commit's projects change."), PhabricatorAuditTransaction::MAILTAG_COMMENT => pht('Someone comments on a commit.'), PhabricatorAuditTransaction::MAILTAG_OTHER => pht('Other commit activity not listed above occurs.'), ); } protected function shouldApplyHeraldRules( PhabricatorLiskDAO $object, array $xactions) { foreach ($xactions as $xaction) { switch ($xaction->getTransactionType()) { case PhabricatorAuditTransaction::TYPE_COMMIT: $repository = $object->getRepository(); $publisher = $repository->newPublisher(); if (!$publisher->shouldPublishCommit($object)) { return false; } return true; default: break; } } return parent::shouldApplyHeraldRules($object, $xactions); } protected function buildHeraldAdapter( PhabricatorLiskDAO $object, array $xactions) { return id(new HeraldCommitAdapter()) ->setObject($object); } protected function didApplyHeraldRules( PhabricatorLiskDAO $object, HeraldAdapter $adapter, HeraldTranscript $transcript) { $limit = self::MAX_FILES_SHOWN_IN_EMAIL; $files = $adapter->loadAffectedPaths(); sort($files); if (count($files) > $limit) { array_splice($files, $limit); $files[] = pht( '(This commit affected more than %d files. Only %d are shown here '. 'and additional ones are truncated.)', $limit, $limit); } $this->affectedFiles = implode("\n", $files); return array(); } private function isCommitMostlyImported(PhabricatorLiskDAO $object) { $has_message = PhabricatorRepositoryCommit::IMPORTED_MESSAGE; $has_changes = PhabricatorRepositoryCommit::IMPORTED_CHANGE; // Don't publish feed stories or email about events which occur during // import. In particular, this affects tasks being attached when they are // closed by "Fixes Txxxx" in a commit message. See T5851. $mask = ($has_message | $has_changes); return $object->isPartiallyImported($mask); } private function shouldPublishRepositoryActivity( PhabricatorLiskDAO $object, array $xactions) { // not every code path loads the repository so tread carefully // TODO: They should, and then we should simplify this. $repository = $object->getRepository($assert_attached = false); if ($repository != PhabricatorLiskDAO::ATTACHABLE) { $publisher = $repository->newPublisher(); if (!$publisher->shouldPublishCommit($object)) { return false; } } return $this->isCommitMostlyImported($object); } protected function shouldSendMail( PhabricatorLiskDAO $object, array $xactions) { return $this->shouldPublishRepositoryActivity($object, $xactions); } protected function shouldEnableMentions( PhabricatorLiskDAO $object, array $xactions) { return $this->shouldPublishRepositoryActivity($object, $xactions); } protected function shouldPublishFeedStory( PhabricatorLiskDAO $object, array $xactions) { return $this->shouldPublishRepositoryActivity($object, $xactions); } protected function getCustomWorkerState() { return array( 'rawPatch' => $this->rawPatch, 'affectedFiles' => $this->affectedFiles, 'auditorPHIDs' => $this->auditorPHIDs, ); } protected function getCustomWorkerStateEncoding() { return array( 'rawPatch' => self::STORAGE_ENCODING_BINARY, ); } protected function loadCustomWorkerState(array $state) { $this->rawPatch = idx($state, 'rawPatch'); $this->affectedFiles = idx($state, 'affectedFiles'); $this->auditorPHIDs = idx($state, 'auditorPHIDs'); return $this; } protected function willPublish(PhabricatorLiskDAO $object, array $xactions) { return id(new DiffusionCommitQuery()) ->setViewer($this->requireActor()) ->withIDs(array($object->getID())) ->needAuditRequests(true) ->needCommitData(true) ->executeOne(); } private function requireAuditors(PhabricatorRepositoryCommit $commit) { if ($commit->hasAttachedAudits()) { return; } $with_auditors = id(new DiffusionCommitQuery()) ->setViewer($this->getActor()) ->needAuditRequests(true) ->withPHIDs(array($commit->getPHID())) ->executeOne(); if (!$with_auditors) { throw new Exception( pht( 'Failed to reload commit ("%s").', $commit->getPHID())); } $commit->attachAudits($with_auditors->getAudits()); } } diff --git a/src/applications/differential/editor/DifferentialTransactionEditor.php b/src/applications/differential/editor/DifferentialTransactionEditor.php index 090592046b..88f13ff828 100644 --- a/src/applications/differential/editor/DifferentialTransactionEditor.php +++ b/src/applications/differential/editor/DifferentialTransactionEditor.php @@ -1,1725 +1,1725 @@ firstBroadcast; } public function getDiffUpdateTransaction(array $xactions) { $type_update = DifferentialRevisionUpdateTransaction::TRANSACTIONTYPE; foreach ($xactions as $xaction) { if ($xaction->getTransactionType() == $type_update) { return $xaction; } } return null; } public function setIsCloseByCommit($is_close_by_commit) { $this->isCloseByCommit = $is_close_by_commit; return $this; } public function getIsCloseByCommit() { return $this->isCloseByCommit; } public function setChangedPriorToCommitURI($uri) { $this->changedPriorToCommitURI = $uri; return $this; } public function getChangedPriorToCommitURI() { return $this->changedPriorToCommitURI; } public function setRepositoryPHIDOverride($phid_or_null) { $this->repositoryPHIDOverride = $phid_or_null; return $this; } public function getTransactionTypes() { $types = parent::getTransactionTypes(); $types[] = PhabricatorTransactions::TYPE_COMMENT; $types[] = PhabricatorTransactions::TYPE_VIEW_POLICY; $types[] = PhabricatorTransactions::TYPE_EDIT_POLICY; $types[] = PhabricatorTransactions::TYPE_INLINESTATE; $types[] = DifferentialTransaction::TYPE_INLINE; return $types; } protected function getCustomTransactionOldValue( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case DifferentialTransaction::TYPE_INLINE: return null; } return parent::getCustomTransactionOldValue($object, $xaction); } protected function getCustomTransactionNewValue( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case DifferentialTransaction::TYPE_INLINE: return null; } return parent::getCustomTransactionNewValue($object, $xaction); } protected function applyCustomInternalTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case DifferentialTransaction::TYPE_INLINE: return; } return parent::applyCustomInternalTransaction($object, $xaction); } protected function expandTransactions( PhabricatorLiskDAO $object, array $xactions) { foreach ($xactions as $xaction) { switch ($xaction->getTransactionType()) { case PhabricatorTransactions::TYPE_INLINESTATE: // If we have an "Inline State" transaction already, the caller // built it for us so we don't need to expand it again. $this->didExpandInlineState = true; break; case DifferentialRevisionPlanChangesTransaction::TRANSACTIONTYPE: if ($xaction->getMetadataValue('draft.demote')) { $this->isDraftDemotion = true; } break; } } $this->wasBroadcasting = $object->getShouldBroadcast(); return parent::expandTransactions($object, $xactions); } protected function expandTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { $results = parent::expandTransaction($object, $xaction); $actor = $this->getActor(); $actor_phid = $this->getActingAsPHID(); $type_edge = PhabricatorTransactions::TYPE_EDGE; $edge_ref_task = DifferentialRevisionHasTaskEdgeType::EDGECONST; $want_downgrade = array(); $must_downgrade = array(); if ($this->getIsCloseByCommit()) { // Never downgrade reviewers when we're closing a revision after a // commit. } else { switch ($xaction->getTransactionType()) { case DifferentialRevisionUpdateTransaction::TRANSACTIONTYPE: $want_downgrade[] = DifferentialReviewerStatus::STATUS_ACCEPTED; $want_downgrade[] = DifferentialReviewerStatus::STATUS_REJECTED; break; case DifferentialRevisionRequestReviewTransaction::TRANSACTIONTYPE: if (!$object->isChangePlanned()) { // If the old state isn't "Changes Planned", downgrade the accepts // even if they're sticky. // We don't downgrade for "Changes Planned" to allow an author to // undo a "Plan Changes" by immediately following it up with a // "Request Review". $want_downgrade[] = DifferentialReviewerStatus::STATUS_ACCEPTED; $must_downgrade[] = DifferentialReviewerStatus::STATUS_ACCEPTED; } $want_downgrade[] = DifferentialReviewerStatus::STATUS_REJECTED; break; } } if ($want_downgrade) { $void_type = DifferentialRevisionVoidTransaction::TRANSACTIONTYPE; $results[] = id(new DifferentialTransaction()) ->setTransactionType($void_type) ->setIgnoreOnNoEffect(true) ->setMetadataValue('void.force', $must_downgrade) ->setNewValue($want_downgrade); } $is_commandeer = false; switch ($xaction->getTransactionType()) { case DifferentialRevisionUpdateTransaction::TRANSACTIONTYPE: if ($this->getIsCloseByCommit()) { // Don't bother with any of this if this update is a side effect of // commit detection. break; } // When a revision is updated and the diff comes from a branch named // "T123" or similar, automatically associate the commit with the // task that the branch names. $maniphest = 'PhabricatorManiphestApplication'; if (PhabricatorApplication::isClassInstalled($maniphest)) { $diff = $this->requireDiff($xaction->getNewValue()); $branch = $diff->getBranch(); // No "$", to allow for branches like T123_demo. $match = null; if (preg_match('/^T(\d+)/i', $branch, $match)) { $task_id = $match[1]; $tasks = id(new ManiphestTaskQuery()) ->setViewer($this->getActor()) ->withIDs(array($task_id)) ->execute(); if ($tasks) { $task = head($tasks); $task_phid = $task->getPHID(); $results[] = id(new DifferentialTransaction()) ->setTransactionType($type_edge) ->setMetadataValue('edge:type', $edge_ref_task) ->setIgnoreOnNoEffect(true) ->setNewValue(array('+' => array($task_phid => $task_phid))); } } } break; case DifferentialRevisionCommandeerTransaction::TRANSACTIONTYPE: $is_commandeer = true; break; } if ($is_commandeer) { $results[] = $this->newCommandeerReviewerTransaction($object); } if (!$this->didExpandInlineState) { switch ($xaction->getTransactionType()) { case PhabricatorTransactions::TYPE_COMMENT: case DifferentialRevisionUpdateTransaction::TRANSACTIONTYPE: case DifferentialTransaction::TYPE_INLINE: $this->didExpandInlineState = true; $query_template = id(new DifferentialDiffInlineCommentQuery()) ->withRevisionPHIDs(array($object->getPHID())); $state_xaction = $this->newInlineStateTransaction( $object, $query_template); if ($state_xaction) { $results[] = $state_xaction; } break; } } return $results; } protected function applyCustomExternalTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case DifferentialTransaction::TYPE_INLINE: $reply = $xaction->getComment()->getReplyToComment(); if ($reply && !$reply->getHasReplies()) { $reply->setHasReplies(1)->save(); } return; } return parent::applyCustomExternalTransaction($object, $xaction); } protected function applyBuiltinExternalTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case PhabricatorTransactions::TYPE_INLINESTATE: $table = new DifferentialTransactionComment(); $conn_w = $table->establishConnection('w'); foreach ($xaction->getNewValue() as $phid => $state) { queryfx( $conn_w, 'UPDATE %T SET fixedState = %s WHERE phid = %s', $table->getTableName(), $state, $phid); } break; } return parent::applyBuiltinExternalTransaction($object, $xaction); } protected function applyFinalEffects( PhabricatorLiskDAO $object, array $xactions) { // Load the most up-to-date version of the revision and its reviewers, // so we don't need to try to deduce the state of reviewers by examining // all the changes made by the transactions. Then, update the reviewers // on the object to make sure we're acting on the current reviewer set // (and, for example, sending mail to the right people). $new_revision = id(new DifferentialRevisionQuery()) ->setViewer($this->getActor()) ->needReviewers(true) ->needActiveDiffs(true) ->withIDs(array($object->getID())) ->executeOne(); if (!$new_revision) { throw new Exception( pht('Failed to load revision from transaction finalization.')); } $object->attachReviewers($new_revision->getReviewers()); $object->attachActiveDiff($new_revision->getActiveDiff()); $object->attachRepository($new_revision->getRepository()); foreach ($xactions as $xaction) { switch ($xaction->getTransactionType()) { case DifferentialRevisionUpdateTransaction::TRANSACTIONTYPE: $diff = $this->requireDiff($xaction->getNewValue(), true); // Update these denormalized index tables when we attach a new // diff to a revision. $this->updateRevisionHashTable($object, $diff); $this->updateAffectedPathTable($object, $diff); break; } } $xactions = $this->updateReviewStatus($object, $xactions); $this->markReviewerComments($object, $xactions); return $xactions; } private function updateReviewStatus( DifferentialRevision $revision, array $xactions) { $was_accepted = $revision->isAccepted(); $was_revision = $revision->isNeedsRevision(); $was_review = $revision->isNeedsReview(); if (!$was_accepted && !$was_revision && !$was_review) { // Revisions can't transition out of other statuses (like closed or // abandoned) as a side effect of reviewer status changes. return $xactions; } // Try to move a revision to "accepted". We look for: // // - at least one accepting reviewer who is a user; and // - no rejects; and // - no rejects of older diffs; and // - no blocking reviewers. $has_accepting_user = false; $has_rejecting_reviewer = false; $has_rejecting_older_reviewer = false; $has_blocking_reviewer = false; $active_diff = $revision->getActiveDiff(); foreach ($revision->getReviewers() as $reviewer) { $reviewer_status = $reviewer->getReviewerStatus(); switch ($reviewer_status) { case DifferentialReviewerStatus::STATUS_REJECTED: $active_phid = $active_diff->getPHID(); if ($reviewer->isRejected($active_phid)) { $has_rejecting_reviewer = true; } else { $has_rejecting_older_reviewer = true; } break; case DifferentialReviewerStatus::STATUS_REJECTED_OLDER: $has_rejecting_older_reviewer = true; break; case DifferentialReviewerStatus::STATUS_BLOCKING: $has_blocking_reviewer = true; break; case DifferentialReviewerStatus::STATUS_ACCEPTED: if ($reviewer->isUser()) { $active_phid = $active_diff->getPHID(); if ($reviewer->isAccepted($active_phid)) { $has_accepting_user = true; } } break; } } $new_status = null; if ($has_accepting_user && !$has_rejecting_reviewer && !$has_rejecting_older_reviewer && !$has_blocking_reviewer) { $new_status = DifferentialRevisionStatus::ACCEPTED; } else if ($has_rejecting_reviewer) { // This isn't accepted, and there's at least one rejecting reviewer, // so the revision needs changes. This usually happens after a // "reject". $new_status = DifferentialRevisionStatus::NEEDS_REVISION; } else if ($was_accepted) { // This revision was accepted, but it no longer satisfies the // conditions for acceptance. This usually happens after an accepting // reviewer resigns or is removed. $new_status = DifferentialRevisionStatus::NEEDS_REVIEW; } else if ($was_revision) { // This revision was "Needs Revision", but no longer has any rejecting // reviewers. This usually happens after the last rejecting reviewer // resigns or is removed. Put the revision back in "Needs Review". $new_status = DifferentialRevisionStatus::NEEDS_REVIEW; } if ($new_status === null) { return $xactions; } $old_status = $revision->getModernRevisionStatus(); if ($new_status == $old_status) { return $xactions; } $xaction = id(new DifferentialTransaction()) ->setTransactionType( DifferentialRevisionStatusTransaction::TRANSACTIONTYPE) ->setOldValue($old_status) ->setNewValue($new_status); $xaction = $this->populateTransaction($revision, $xaction) ->save(); $xactions[] = $xaction; // Save the status adjustment we made earlier. $revision ->setModernRevisionStatus($new_status) ->save(); return $xactions; } protected function sortTransactions(array $xactions) { $xactions = parent::sortTransactions($xactions); $head = array(); $tail = array(); foreach ($xactions as $xaction) { $type = $xaction->getTransactionType(); if ($type == DifferentialTransaction::TYPE_INLINE) { $tail[] = $xaction; } else { $head[] = $xaction; } } return array_values(array_merge($head, $tail)); } protected function shouldPublishFeedStory( PhabricatorLiskDAO $object, array $xactions) { if (!$object->getShouldBroadcast()) { return false; } return true; } protected function shouldSendMail( PhabricatorLiskDAO $object, array $xactions) { return true; } protected function getMailTo(PhabricatorLiskDAO $object) { if ($object->getShouldBroadcast()) { $this->requireReviewers($object); $phids = array(); $phids[] = $object->getAuthorPHID(); foreach ($object->getReviewers() as $reviewer) { if ($reviewer->isResigned()) { continue; } $phids[] = $reviewer->getReviewerPHID(); } return $phids; } // If we're demoting a draft after a build failure, just notify the author. if ($this->isDraftDemotion) { $author_phid = $object->getAuthorPHID(); return array( $author_phid, ); } return array(); } protected function getMailCC(PhabricatorLiskDAO $object) { if (!$object->getShouldBroadcast()) { return array(); } return parent::getMailCC($object); } protected function newMailUnexpandablePHIDs(PhabricatorLiskDAO $object) { $this->requireReviewers($object); $phids = array(); foreach ($object->getReviewers() as $reviewer) { if ($reviewer->isResigned()) { $phids[] = $reviewer->getReviewerPHID(); } } return $phids; } protected function getMailAction( PhabricatorLiskDAO $object, array $xactions) { $show_lines = false; if ($this->isFirstBroadcast()) { $action = pht('Request'); $show_lines = true; } else { $action = parent::getMailAction($object, $xactions); $strongest = $this->getStrongestAction($object, $xactions); $type_update = DifferentialRevisionUpdateTransaction::TRANSACTIONTYPE; if ($strongest->getTransactionType() == $type_update) { $show_lines = true; } } if ($show_lines) { $count = new PhutilNumber($object->getLineCount()); $action = pht('%s] [%s', $action, $object->getRevisionScaleGlyphs()); } return $action; } protected function getMailSubjectPrefix() { return pht('[Differential]'); } protected function getMailThreadID(PhabricatorLiskDAO $object) { // This is nonstandard, but retains threading with older messages. $phid = $object->getPHID(); return "differential-rev-{$phid}-req"; } protected function buildReplyHandler(PhabricatorLiskDAO $object) { return id(new DifferentialReplyHandler()) ->setMailReceiver($object); } protected function buildMailTemplate(PhabricatorLiskDAO $object) { $monogram = $object->getMonogram(); $title = $object->getTitle(); return id(new PhabricatorMetaMTAMail()) ->setSubject(pht('%s: %s', $monogram, $title)) ->setMustEncryptSubject(pht('%s: Revision Updated', $monogram)) ->setMustEncryptURI($object->getURI()); } protected function getTransactionsForMail( PhabricatorLiskDAO $object, array $xactions) { // If this is the first time we're sending mail about this revision, we // generate mail for all prior transactions, not just whatever is being // applied now. This gets the "added reviewers" lines and other relevant // information into the mail. if ($this->isFirstBroadcast()) { return $this->loadUnbroadcastTransactions($object); } return $xactions; } - protected function getObjectLinkButtonLabelForMail() { + protected function getObjectLinkButtonLabelForMail( + PhabricatorLiskDAO $object) { return pht('View Revision'); } protected function buildMailBody( PhabricatorLiskDAO $object, array $xactions) { $viewer = $this->requireActor(); $body = id(new PhabricatorMetaMTAMailBody()) ->setViewer($viewer); - $revision_uri = $object->getURI(); - $revision_uri = PhabricatorEnv::getProductionURI($revision_uri); + $revision_uri = $this->getObjectLinkButtonURIForMail($object); $new_uri = $revision_uri.'/new/'; $this->addHeadersAndCommentsToMailBody( $body, $xactions, $this->getObjectLinkButtonLabelForMail($object), $revision_uri); $type_inline = DifferentialTransaction::TYPE_INLINE; $inlines = array(); foreach ($xactions as $xaction) { if ($xaction->getTransactionType() == $type_inline) { $inlines[] = $xaction; } } if ($inlines) { $this->appendInlineCommentsForMail($object, $inlines, $body); } $update_xaction = null; foreach ($xactions as $xaction) { switch ($xaction->getTransactionType()) { case DifferentialRevisionUpdateTransaction::TRANSACTIONTYPE: $update_xaction = $xaction; break; } } if ($update_xaction) { $diff = $this->requireDiff($update_xaction->getNewValue(), true); } else { $diff = null; } $changed_uri = $this->getChangedPriorToCommitURI(); if ($changed_uri) { $body->addLinkSection( pht('CHANGED PRIOR TO COMMIT'), $changed_uri); } $this->addCustomFieldsToMailBody($body, $object, $xactions); if (!$this->isFirstBroadcast()) { $body->addLinkSection(pht('CHANGES SINCE LAST ACTION'), $new_uri); } $body->addLinkSection( pht('REVISION DETAIL'), $revision_uri); if ($update_xaction) { $body->addTextSection( pht('AFFECTED FILES'), $this->renderAffectedFilesForMail($diff)); $config_key_inline = 'metamta.differential.inline-patches'; $config_inline = PhabricatorEnv::getEnvConfig($config_key_inline); $config_key_attach = 'metamta.differential.attach-patches'; $config_attach = PhabricatorEnv::getEnvConfig($config_key_attach); if ($config_inline || $config_attach) { $body_limit = PhabricatorEnv::getEnvConfig('metamta.email-body-limit'); try { $patch = $this->buildPatchForMail($diff, $body_limit); } catch (ArcanistDiffByteSizeException $ex) { $patch = null; } if (($patch !== null) && $config_inline) { $lines = substr_count($patch, "\n"); $bytes = strlen($patch); // Limit the patch size to the smaller of 256 bytes per line or // the mail body limit. This prevents degenerate behavior for patches // with one line that is 10MB long. See T11748. $byte_limits = array(); $byte_limits[] = (256 * $config_inline); $byte_limits[] = $body_limit; $byte_limit = min($byte_limits); $lines_ok = ($lines <= $config_inline); $bytes_ok = ($bytes <= $byte_limit); if ($lines_ok && $bytes_ok) { $this->appendChangeDetailsForMail($object, $diff, $patch, $body); } else { // TODO: Provide a helpful message about the patch being too // large or lengthy here. } } if (($patch !== null) && $config_attach) { // See T12033, T11767, and PHI55. This is a crude fix to stop the // major concrete problems that lackluster email size limits cause. if (strlen($patch) < $body_limit) { $name = pht('D%s.%s.patch', $object->getID(), $diff->getID()); $mime_type = 'text/x-patch; charset=utf-8'; $body->addAttachment( new PhabricatorMailAttachment($patch, $name, $mime_type)); } } } } return $body; } public function getMailTagsMap() { return array( DifferentialTransaction::MAILTAG_REVIEW_REQUEST => pht('A revision is created.'), DifferentialTransaction::MAILTAG_UPDATED => pht('A revision is updated.'), DifferentialTransaction::MAILTAG_COMMENT => pht('Someone comments on a revision.'), DifferentialTransaction::MAILTAG_CLOSED => pht('A revision is closed.'), DifferentialTransaction::MAILTAG_REVIEWERS => pht("A revision's reviewers change."), DifferentialTransaction::MAILTAG_CC => pht("A revision's CCs change."), DifferentialTransaction::MAILTAG_OTHER => pht('Other revision activity not listed above occurs.'), ); } protected function supportsSearch() { return true; } protected function expandCustomRemarkupBlockTransactions( PhabricatorLiskDAO $object, array $xactions, array $changes, PhutilMarkupEngine $engine) { // For "Fixes ..." and "Depends on ...", we're only going to look at // content blocks which are part of the revision itself (like "Summary" // and "Test Plan"), not comments. $content_parts = array(); foreach ($changes as $change) { if ($change->getTransaction()->isCommentTransaction()) { continue; } $content_parts[] = $change->getNewValue(); } if (!$content_parts) { return array(); } $content_block = implode("\n\n", $content_parts); $task_map = array(); $task_refs = id(new ManiphestCustomFieldStatusParser()) ->parseCorpus($content_block); foreach ($task_refs as $match) { foreach ($match['monograms'] as $monogram) { $task_id = (int)trim($monogram, 'tT'); $task_map[$task_id] = true; } } $rev_map = array(); $rev_refs = id(new DifferentialCustomFieldDependsOnParser()) ->parseCorpus($content_block); foreach ($rev_refs as $match) { foreach ($match['monograms'] as $monogram) { $rev_id = (int)trim($monogram, 'dD'); $rev_map[$rev_id] = true; } } $edges = array(); $task_phids = array(); $rev_phids = array(); if ($task_map) { $tasks = id(new ManiphestTaskQuery()) ->setViewer($this->getActor()) ->withIDs(array_keys($task_map)) ->execute(); if ($tasks) { $task_phids = mpull($tasks, 'getPHID', 'getPHID'); $edge_related = DifferentialRevisionHasTaskEdgeType::EDGECONST; $edges[$edge_related] = $task_phids; } } if ($rev_map) { $revs = id(new DifferentialRevisionQuery()) ->setViewer($this->getActor()) ->withIDs(array_keys($rev_map)) ->execute(); $rev_phids = mpull($revs, 'getPHID', 'getPHID'); // NOTE: Skip any write attempts if a user cleverly implies a revision // depends upon itself. unset($rev_phids[$object->getPHID()]); if ($revs) { $depends = DifferentialRevisionDependsOnRevisionEdgeType::EDGECONST; $edges[$depends] = $rev_phids; } } $revert_refs = id(new DifferentialCustomFieldRevertsParser()) ->parseCorpus($content_block); $revert_monograms = array(); foreach ($revert_refs as $match) { foreach ($match['monograms'] as $monogram) { $revert_monograms[] = $monogram; } } if ($revert_monograms) { $revert_objects = DiffusionCommitRevisionQuery::loadRevertedObjects( $this->getActor(), $object, $revert_monograms, null); $revert_phids = mpull($revert_objects, 'getPHID', 'getPHID'); $revert_type = DiffusionCommitRevertsCommitEdgeType::EDGECONST; $edges[$revert_type] = $revert_phids; } else { $revert_phids = array(); } $this->addUnmentionablePHIDs($task_phids); $this->addUnmentionablePHIDs($rev_phids); $this->addUnmentionablePHIDs($revert_phids); $result = array(); foreach ($edges as $type => $specs) { $result[] = id(new DifferentialTransaction()) ->setTransactionType(PhabricatorTransactions::TYPE_EDGE) ->setMetadataValue('edge:type', $type) ->setNewValue(array('+' => $specs)); } return $result; } private function appendInlineCommentsForMail( PhabricatorLiskDAO $object, array $inlines, PhabricatorMetaMTAMailBody $body) { $limit = 100; $limit_note = null; if (count($inlines) > $limit) { $limit_note = pht( '(Showing first %s of %s inline comments.)', new PhutilNumber($limit), phutil_count($inlines)); $inlines = array_slice($inlines, 0, $limit, true); } $section = id(new DifferentialInlineCommentMailView()) ->setViewer($this->getActor()) ->setInlines($inlines) ->buildMailSection(); $header = pht('INLINE COMMENTS'); $section_text = "\n".$section->getPlaintext(); if ($limit_note) { $section_text = $limit_note."\n".$section_text; } $style = array( 'margin: 6px 0 12px 0;', ); $section_html = phutil_tag( 'div', array( 'style' => implode(' ', $style), ), $section->getHTML()); if ($limit_note) { $section_html = array( phutil_tag( 'em', array(), $limit_note), $section_html, ); } $body->addPlaintextSection($header, $section_text, false); $body->addHTMLSection($header, $section_html); } private function appendChangeDetailsForMail( PhabricatorLiskDAO $object, DifferentialDiff $diff, $patch, PhabricatorMetaMTAMailBody $body) { $section = id(new DifferentialChangeDetailMailView()) ->setViewer($this->getActor()) ->setDiff($diff) ->setPatch($patch) ->buildMailSection(); $header = pht('CHANGE DETAILS'); $section_text = "\n".$section->getPlaintext(); $style = array( 'margin: 6px 0 12px 0;', ); $section_html = phutil_tag( 'div', array( 'style' => implode(' ', $style), ), $section->getHTML()); $body->addPlaintextSection($header, $section_text, false); $body->addHTMLSection($header, $section_html); } private function loadDiff($phid, $need_changesets = false) { $query = id(new DifferentialDiffQuery()) ->withPHIDs(array($phid)) ->setViewer($this->getActor()); if ($need_changesets) { $query->needChangesets(true); } return $query->executeOne(); } public function requireDiff($phid, $need_changesets = false) { $diff = $this->loadDiff($phid, $need_changesets); if (!$diff) { throw new Exception(pht('Diff "%s" does not exist!', $phid)); } return $diff; } /* -( Herald Integration )------------------------------------------------- */ protected function shouldApplyHeraldRules( PhabricatorLiskDAO $object, array $xactions) { return true; } protected function didApplyHeraldRules( PhabricatorLiskDAO $object, HeraldAdapter $adapter, HeraldTranscript $transcript) { $repository = $object->getRepository(); if (!$repository) { return array(); } $diff = $this->ownersDiff; $changesets = $this->ownersChangesets; $this->ownersDiff = null; $this->ownersChangesets = null; if (!$changesets) { return array(); } $packages = PhabricatorOwnersPackage::loadAffectedPackagesForChangesets( $repository, $diff, $changesets); if (!$packages) { return array(); } // Identify the packages with "Non-Owner Author" review rules and remove // them if the author has authority over the package. $autoreview_map = PhabricatorOwnersPackage::getAutoreviewOptionsMap(); $need_authority = array(); foreach ($packages as $package) { $autoreview_setting = $package->getAutoReview(); $spec = idx($autoreview_map, $autoreview_setting); if (!$spec) { continue; } if (idx($spec, 'authority')) { $need_authority[$package->getPHID()] = $package->getPHID(); } } if ($need_authority) { $authority = id(new PhabricatorOwnersPackageQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withPHIDs($need_authority) ->withAuthorityPHIDs(array($object->getAuthorPHID())) ->execute(); $authority = mpull($authority, null, 'getPHID'); foreach ($packages as $key => $package) { $package_phid = $package->getPHID(); if (isset($authority[$package_phid])) { unset($packages[$key]); continue; } } if (!$packages) { return array(); } } $auto_subscribe = array(); $auto_review = array(); $auto_block = array(); foreach ($packages as $package) { switch ($package->getAutoReview()) { case PhabricatorOwnersPackage::AUTOREVIEW_REVIEW: case PhabricatorOwnersPackage::AUTOREVIEW_REVIEW_ALWAYS: $auto_review[] = $package; break; case PhabricatorOwnersPackage::AUTOREVIEW_BLOCK: case PhabricatorOwnersPackage::AUTOREVIEW_BLOCK_ALWAYS: $auto_block[] = $package; break; case PhabricatorOwnersPackage::AUTOREVIEW_SUBSCRIBE: case PhabricatorOwnersPackage::AUTOREVIEW_SUBSCRIBE_ALWAYS: $auto_subscribe[] = $package; break; case PhabricatorOwnersPackage::AUTOREVIEW_NONE: default: break; } } $owners_phid = id(new PhabricatorOwnersApplication()) ->getPHID(); $xactions = array(); if ($auto_subscribe) { $xactions[] = $object->getApplicationTransactionTemplate() ->setAuthorPHID($owners_phid) ->setTransactionType(PhabricatorTransactions::TYPE_SUBSCRIBERS) ->setNewValue( array( '+' => mpull($auto_subscribe, 'getPHID'), )); } $specs = array( array($auto_review, false), array($auto_block, true), ); foreach ($specs as $spec) { list($reviewers, $blocking) = $spec; if (!$reviewers) { continue; } $phids = mpull($reviewers, 'getPHID'); $xaction = $this->newAutoReviewTransaction($object, $phids, $blocking); if ($xaction) { $xactions[] = $xaction; } } return $xactions; } private function newAutoReviewTransaction( PhabricatorLiskDAO $object, array $phids, $is_blocking) { // TODO: This is substantially similar to DifferentialReviewersHeraldAction // and both are needlessly complex. This logic should live in the normal // transaction application pipeline. See T10967. $reviewers = $object->getReviewers(); $reviewers = mpull($reviewers, null, 'getReviewerPHID'); if ($is_blocking) { $new_status = DifferentialReviewerStatus::STATUS_BLOCKING; } else { $new_status = DifferentialReviewerStatus::STATUS_ADDED; } $new_strength = DifferentialReviewerStatus::getStatusStrength( $new_status); $current = array(); foreach ($phids as $phid) { if (!isset($reviewers[$phid])) { continue; } // If we're applying a stronger status (usually, upgrading a reviewer // into a blocking reviewer), skip this check so we apply the change. $old_strength = DifferentialReviewerStatus::getStatusStrength( $reviewers[$phid]->getReviewerStatus()); if ($old_strength <= $new_strength) { continue; } $current[] = $phid; } $phids = array_diff($phids, $current); if (!$phids) { return null; } $phids = array_fuse($phids); $value = array(); foreach ($phids as $phid) { if ($is_blocking) { $value[] = 'blocking('.$phid.')'; } else { $value[] = $phid; } } $owners_phid = id(new PhabricatorOwnersApplication()) ->getPHID(); $reviewers_type = DifferentialRevisionReviewersTransaction::TRANSACTIONTYPE; return $object->getApplicationTransactionTemplate() ->setAuthorPHID($owners_phid) ->setTransactionType($reviewers_type) ->setNewValue( array( '+' => $value, )); } protected function buildHeraldAdapter( PhabricatorLiskDAO $object, array $xactions) { $revision = id(new DifferentialRevisionQuery()) ->setViewer($this->getActor()) ->withPHIDs(array($object->getPHID())) ->needActiveDiffs(true) ->needReviewers(true) ->executeOne(); if (!$revision) { throw new Exception( pht('Failed to load revision for Herald adapter construction!')); } $adapter = HeraldDifferentialRevisionAdapter::newLegacyAdapter( $revision, $revision->getActiveDiff()); // If the object is still a draft, prevent "Send me an email" and other // similar rules from acting yet. if (!$object->getShouldBroadcast()) { $adapter->setForbiddenAction( HeraldMailableState::STATECONST, DifferentialHeraldStateReasons::REASON_DRAFT); } // If this edit didn't actually change the diff (for example, a user // edited the title or changed subscribers), prevent "Run build plan" // and other similar rules from acting yet, since the build results will // not (or, at least, should not) change unless the actual source changes. // We also don't run Differential builds if the update was caused by // discovering a commit, as the expectation is that Diffusion builds take // over once things land. $has_update = false; $has_commit = false; $type_update = DifferentialRevisionUpdateTransaction::TRANSACTIONTYPE; foreach ($xactions as $xaction) { if ($xaction->getTransactionType() != $type_update) { continue; } if ($xaction->getMetadataValue('isCommitUpdate')) { $has_commit = true; } else { $has_update = true; } break; } if ($has_commit) { $adapter->setForbiddenAction( HeraldBuildableState::STATECONST, DifferentialHeraldStateReasons::REASON_LANDED); } else if (!$has_update) { $adapter->setForbiddenAction( HeraldBuildableState::STATECONST, DifferentialHeraldStateReasons::REASON_UNCHANGED); } return $adapter; } /** * Update the table which links Differential revisions to paths they affect, * so Diffusion can efficiently find pending revisions for a given file. */ private function updateAffectedPathTable( DifferentialRevision $revision, DifferentialDiff $diff) { $repository = $revision->getRepository(); if (!$repository) { // The repository where the code lives is untracked. return; } $path_prefix = null; $local_root = $diff->getSourceControlPath(); if ($local_root) { // We're in a working copy which supports subdirectory checkouts (e.g., // SVN) so we need to figure out what prefix we should add to each path // (e.g., trunk/projects/example/) to get the absolute path from the // root of the repository. DVCS systems like Git and Mercurial are not // affected. // Normalize both paths and check if the repository root is a prefix of // the local root. If so, throw it away. Note that this correctly handles // the case where the remote path is "/". $local_root = id(new PhutilURI($local_root))->getPath(); $local_root = rtrim($local_root, '/'); $repo_root = id(new PhutilURI($repository->getRemoteURI()))->getPath(); $repo_root = rtrim($repo_root, '/'); if (!strncmp($repo_root, $local_root, strlen($repo_root))) { $path_prefix = substr($local_root, strlen($repo_root)); } } $changesets = $diff->getChangesets(); $paths = array(); foreach ($changesets as $changeset) { $paths[] = $path_prefix.'/'.$changeset->getFilename(); } // If this change affected paths, save the changesets so we can apply // Owners rules to them later. if ($paths) { $this->ownersDiff = $diff; $this->ownersChangesets = $changesets; } // Mark this as also touching all parent paths, so you can see all pending // changes to any file within a directory. $all_paths = array(); foreach ($paths as $local) { foreach (DiffusionPathIDQuery::expandPathToRoot($local) as $path) { $all_paths[$path] = true; } } $all_paths = array_keys($all_paths); $path_ids = PhabricatorRepositoryCommitChangeParserWorker::lookupOrCreatePaths( $all_paths); $table = new DifferentialAffectedPath(); $conn_w = $table->establishConnection('w'); $sql = array(); foreach ($path_ids as $path_id) { $sql[] = qsprintf( $conn_w, '(%d, %d, %d, %d)', $repository->getID(), $path_id, time(), $revision->getID()); } queryfx( $conn_w, 'DELETE FROM %T WHERE revisionID = %d', $table->getTableName(), $revision->getID()); foreach (array_chunk($sql, 256) as $chunk) { queryfx( $conn_w, 'INSERT INTO %T (repositoryID, pathID, epoch, revisionID) VALUES %LQ', $table->getTableName(), $chunk); } } /** * Update the table connecting revisions to DVCS local hashes, so we can * identify revisions by commit/tree hashes. */ private function updateRevisionHashTable( DifferentialRevision $revision, DifferentialDiff $diff) { $vcs = $diff->getSourceControlSystem(); if ($vcs == DifferentialRevisionControlSystem::SVN) { // Subversion has no local commit or tree hash information, so we don't // have to do anything. return; } $property = id(new DifferentialDiffProperty())->loadOneWhere( 'diffID = %d AND name = %s', $diff->getID(), 'local:commits'); if (!$property) { return; } $hashes = array(); $data = $property->getData(); switch ($vcs) { case DifferentialRevisionControlSystem::GIT: foreach ($data as $commit) { $hashes[] = array( ArcanistDifferentialRevisionHash::HASH_GIT_COMMIT, $commit['commit'], ); $hashes[] = array( ArcanistDifferentialRevisionHash::HASH_GIT_TREE, $commit['tree'], ); } break; case DifferentialRevisionControlSystem::MERCURIAL: foreach ($data as $commit) { $hashes[] = array( ArcanistDifferentialRevisionHash::HASH_MERCURIAL_COMMIT, $commit['rev'], ); } break; } $conn_w = $revision->establishConnection('w'); $sql = array(); foreach ($hashes as $info) { list($type, $hash) = $info; $sql[] = qsprintf( $conn_w, '(%d, %s, %s)', $revision->getID(), $type, $hash); } queryfx( $conn_w, 'DELETE FROM %T WHERE revisionID = %d', ArcanistDifferentialRevisionHash::TABLE_NAME, $revision->getID()); if ($sql) { queryfx( $conn_w, 'INSERT INTO %T (revisionID, type, hash) VALUES %LQ', ArcanistDifferentialRevisionHash::TABLE_NAME, $sql); } } private function renderAffectedFilesForMail(DifferentialDiff $diff) { $changesets = $diff->getChangesets(); $filenames = mpull($changesets, 'getDisplayFilename'); sort($filenames); $count = count($filenames); $max = 250; if ($count > $max) { $filenames = array_slice($filenames, 0, $max); $filenames[] = pht('(%d more files...)', ($count - $max)); } return implode("\n", $filenames); } private function renderPatchHTMLForMail($patch) { return phutil_tag('pre', array('style' => 'font-family: monospace;'), $patch); } private function buildPatchForMail(DifferentialDiff $diff, $byte_limit) { $format = PhabricatorEnv::getEnvConfig('metamta.differential.patch-format'); return id(new DifferentialRawDiffRenderer()) ->setViewer($this->getActor()) ->setFormat($format) ->setChangesets($diff->getChangesets()) ->setByteLimit($byte_limit) ->buildPatch(); } protected function willPublish(PhabricatorLiskDAO $object, array $xactions) { // Reload to pick up the active diff and reviewer status. return id(new DifferentialRevisionQuery()) ->setViewer($this->getActor()) ->needReviewers(true) ->needActiveDiffs(true) ->withIDs(array($object->getID())) ->executeOne(); } protected function getCustomWorkerState() { return array( 'changedPriorToCommitURI' => $this->changedPriorToCommitURI, 'firstBroadcast' => $this->firstBroadcast, 'isDraftDemotion' => $this->isDraftDemotion, ); } protected function loadCustomWorkerState(array $state) { $this->changedPriorToCommitURI = idx($state, 'changedPriorToCommitURI'); $this->firstBroadcast = idx($state, 'firstBroadcast'); $this->isDraftDemotion = idx($state, 'isDraftDemotion'); return $this; } private function newCommandeerReviewerTransaction( DifferentialRevision $revision) { $actor_phid = $this->getActingAsPHID(); $owner_phid = $revision->getAuthorPHID(); // If the user is commandeering, add the previous owner as a // reviewer and remove the actor. $edits = array( '-' => array( $actor_phid, ), '+' => array( $owner_phid, ), ); // NOTE: We're setting setIsCommandeerSideEffect() on this because normally // you can't add a revision's author as a reviewer, but this action swaps // them after validation executes. $xaction_type = DifferentialRevisionReviewersTransaction::TRANSACTIONTYPE; return id(new DifferentialTransaction()) ->setTransactionType($xaction_type) ->setIgnoreOnNoEffect(true) ->setIsCommandeerSideEffect(true) ->setNewValue($edits); } public function getActiveDiff($object) { if ($this->getIsNewObject()) { return null; } else { return $object->getActiveDiff(); } } /** * When a reviewer makes a comment, mark the last revision they commented * on. * * This allows us to show a hint to help authors and other reviewers quickly * distinguish between reviewers who have participated in the discussion and * reviewers who haven't been part of it. */ private function markReviewerComments($object, array $xactions) { $acting_phid = $this->getActingAsPHID(); if (!$acting_phid) { return; } $diff = $this->getActiveDiff($object); if (!$diff) { return; } $has_comment = false; foreach ($xactions as $xaction) { if ($xaction->hasComment()) { $has_comment = true; break; } } if (!$has_comment) { return; } $reviewer_table = new DifferentialReviewer(); $conn = $reviewer_table->establishConnection('w'); queryfx( $conn, 'UPDATE %T SET lastCommentDiffPHID = %s WHERE revisionPHID = %s AND reviewerPHID = %s', $reviewer_table->getTableName(), $diff->getPHID(), $object->getPHID(), $acting_phid); } private function loadUnbroadcastTransactions($object) { $viewer = $this->requireActor(); $xactions = id(new DifferentialTransactionQuery()) ->setViewer($viewer) ->withObjectPHIDs(array($object->getPHID())) ->execute(); return array_reverse($xactions); } protected function didApplyTransactions($object, array $xactions) { // In a moment, we're going to try to publish draft revisions which have // completed all their builds. However, we only want to do that if the // actor is either the revision author or an omnipotent user (generally, // the Harbormaster application). // If we let any actor publish the revision as a side effect of other // changes then an unlucky third party who innocently comments on the draft // can end up racing Harbormaster and promoting the revision. At best, this // is confusing. It can also run into validation problems with the "Request // Review" transaction. See PHI309 for some discussion. $author_phid = $object->getAuthorPHID(); $viewer = $this->requireActor(); $can_undraft = ($this->getActingAsPHID() === $author_phid) || ($viewer->isOmnipotent()); // If a draft revision has no outstanding builds and we're automatically // making drafts public after builds finish, make the revision public. if ($can_undraft) { $auto_undraft = !$object->getHoldAsDraft(); } else { $auto_undraft = false; } $can_promote = false; $can_demote = false; // "Draft" revisions can promote to "Review Requested" after builds pass, // or demote to "Changes Planned" after builds fail. if ($object->isDraft()) { $can_promote = true; $can_demote = true; } // See PHI584. "Changes Planned" revisions which are not yet broadcasting // can promote to "Review Requested" if builds pass. // This pass is presumably the result of someone restarting the builds and // having them work this time, perhaps because the builds are not perfectly // reliable or perhaps because someone fixed some issue with build hardware // or some other dependency. // Currently, there's no legitimate way to end up in this state except // through automatic demotion, so this behavior should not generate an // undue level of confusion or ambiguity. Also note that these changes can // not demote again since they've already been demoted once. if ($object->isChangePlanned()) { if (!$object->getShouldBroadcast()) { $can_promote = true; } } if (($can_promote || $can_demote) && $auto_undraft) { $status = $this->loadCompletedBuildableStatus($object); $is_passed = ($status === HarbormasterBuildableStatus::STATUS_PASSED); $is_failed = ($status === HarbormasterBuildableStatus::STATUS_FAILED); if ($is_passed && $can_promote) { // When Harbormaster moves a revision out of the draft state, we // attribute the action to the revision author since this is more // natural and more useful. // Additionally, we change the acting PHID for the transaction set // to the author if it isn't already a user so that mail comes from // the natural author. $acting_phid = $this->getActingAsPHID(); $user_type = PhabricatorPeopleUserPHIDType::TYPECONST; if (phid_get_type($acting_phid) != $user_type) { $this->setActingAsPHID($author_phid); } $xaction = $object->getApplicationTransactionTemplate() ->setAuthorPHID($author_phid) ->setTransactionType( DifferentialRevisionRequestReviewTransaction::TRANSACTIONTYPE) ->setNewValue(true); // If we're creating this revision and immediately moving it out of // the draft state, mark this as a create transaction so it gets // hidden in the timeline and mail, since it isn't interesting: it // is as though the draft phase never happened. if ($this->getIsNewObject()) { $xaction->setIsCreateTransaction(true); } // Queue this transaction and apply it separately after the current // batch of transactions finishes so that Herald can fire on the new // revision state. See T13027 for discussion. $this->queueTransaction($xaction); } else if ($is_failed && $can_demote) { // When demoting a revision, we act as "Harbormaster" instead of // the author since this feels a little more natural. $harbormaster_phid = id(new PhabricatorHarbormasterApplication()) ->getPHID(); $xaction = $object->getApplicationTransactionTemplate() ->setAuthorPHID($harbormaster_phid) ->setMetadataValue('draft.demote', true) ->setTransactionType( DifferentialRevisionPlanChangesTransaction::TRANSACTIONTYPE) ->setNewValue(true); $this->queueTransaction($xaction); } } // If the revision is new or was a draft, and is no longer a draft, we // might be sending the first email about it. // This might mean it was created directly into a non-draft state, or // it just automatically undrafted after builds finished, or a user // explicitly promoted it out of the draft state with an action like // "Request Review". // If we haven't sent any email about it yet, mark this email as the first // email so the mail gets enriched with "SUMMARY" and "TEST PLAN". $is_new = $this->getIsNewObject(); $was_broadcasting = $this->wasBroadcasting; if ($object->getShouldBroadcast()) { if (!$was_broadcasting || $is_new) { // Mark this as the first broadcast we're sending about the revision // so mail can generate specially. $this->firstBroadcast = true; } } return $xactions; } private function loadCompletedBuildableStatus( DifferentialRevision $revision) { $viewer = $this->requireActor(); $builds = $revision->loadImpactfulBuilds($viewer); return $revision->newBuildableStatusForBuilds($builds); } private function requireReviewers(DifferentialRevision $revision) { if ($revision->hasAttachedReviewers()) { return; } $with_reviewers = id(new DifferentialRevisionQuery()) ->setViewer($this->getActor()) ->needReviewers(true) ->withPHIDs(array($revision->getPHID())) ->executeOne(); if (!$with_reviewers) { throw new Exception( pht( 'Failed to reload revision ("%s").', $revision->getPHID())); } $revision->attachReviewers($with_reviewers->getReviewers()); } } diff --git a/src/applications/maniphest/editor/ManiphestTransactionEditor.php b/src/applications/maniphest/editor/ManiphestTransactionEditor.php index f7e5e25a40..6255903eff 100644 --- a/src/applications/maniphest/editor/ManiphestTransactionEditor.php +++ b/src/applications/maniphest/editor/ManiphestTransactionEditor.php @@ -1,861 +1,862 @@ getTransactionType()) { case PhabricatorTransactions::TYPE_COLUMNS: return null; } } protected function getCustomTransactionNewValue( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case PhabricatorTransactions::TYPE_COLUMNS: return $xaction->getNewValue(); } } protected function transactionHasEffect( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { $old = $xaction->getOldValue(); $new = $xaction->getNewValue(); switch ($xaction->getTransactionType()) { case PhabricatorTransactions::TYPE_COLUMNS: return (bool)$new; } return parent::transactionHasEffect($object, $xaction); } protected function applyCustomInternalTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case PhabricatorTransactions::TYPE_COLUMNS: return; } } protected function applyCustomExternalTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case PhabricatorTransactions::TYPE_COLUMNS: foreach ($xaction->getNewValue() as $move) { $this->applyBoardMove($object, $move); } break; } } protected function applyFinalEffects( PhabricatorLiskDAO $object, array $xactions) { // When we change the status of a task, update tasks this tasks blocks // with a message to the effect of "alincoln resolved blocking task Txxx." $unblock_xaction = null; foreach ($xactions as $xaction) { switch ($xaction->getTransactionType()) { case ManiphestTaskStatusTransaction::TRANSACTIONTYPE: $unblock_xaction = $xaction; break; } } if ($unblock_xaction !== null) { $blocked_phids = PhabricatorEdgeQuery::loadDestinationPHIDs( $object->getPHID(), ManiphestTaskDependedOnByTaskEdgeType::EDGECONST); if ($blocked_phids) { // In theory we could apply these through policies, but that seems a // little bit surprising. For now, use the actor's vision. $blocked_tasks = id(new ManiphestTaskQuery()) ->setViewer($this->getActor()) ->withPHIDs($blocked_phids) ->needSubscriberPHIDs(true) ->needProjectPHIDs(true) ->execute(); $old = $unblock_xaction->getOldValue(); $new = $unblock_xaction->getNewValue(); foreach ($blocked_tasks as $blocked_task) { $parent_xaction = id(new ManiphestTransaction()) ->setTransactionType( ManiphestTaskUnblockTransaction::TRANSACTIONTYPE) ->setOldValue(array($object->getPHID() => $old)) ->setNewValue(array($object->getPHID() => $new)); if ($this->getIsNewObject()) { $parent_xaction->setMetadataValue('blocker.new', true); } $this->newSubEditor() ->setContinueOnNoEffect(true) ->setContinueOnMissingFields(true) ->applyTransactions($blocked_task, array($parent_xaction)); } } } return $xactions; } protected function shouldSendMail( PhabricatorLiskDAO $object, array $xactions) { return true; } protected function getMailSubjectPrefix() { return pht('[Maniphest]'); } protected function getMailThreadID(PhabricatorLiskDAO $object) { return 'maniphest-task-'.$object->getPHID(); } protected function getMailTo(PhabricatorLiskDAO $object) { $phids = array(); if ($object->getOwnerPHID()) { $phids[] = $object->getOwnerPHID(); } $phids[] = $this->getActingAsPHID(); return $phids; } public function getMailTagsMap() { return array( ManiphestTransaction::MAILTAG_STATUS => pht("A task's status changes."), ManiphestTransaction::MAILTAG_OWNER => pht("A task's owner changes."), ManiphestTransaction::MAILTAG_PRIORITY => pht("A task's priority changes."), ManiphestTransaction::MAILTAG_CC => pht("A task's subscribers change."), ManiphestTransaction::MAILTAG_PROJECTS => pht("A task's associated projects change."), ManiphestTransaction::MAILTAG_UNBLOCK => pht("One of a task's subtasks changes status."), ManiphestTransaction::MAILTAG_COLUMN => pht('A task is moved between columns on a workboard.'), ManiphestTransaction::MAILTAG_COMMENT => pht('Someone comments on a task.'), ManiphestTransaction::MAILTAG_OTHER => pht('Other task activity not listed above occurs.'), ); } protected function buildReplyHandler(PhabricatorLiskDAO $object) { return id(new ManiphestReplyHandler()) ->setMailReceiver($object); } protected function buildMailTemplate(PhabricatorLiskDAO $object) { $id = $object->getID(); $title = $object->getTitle(); return id(new PhabricatorMetaMTAMail()) ->setSubject("T{$id}: {$title}"); } - protected function getObjectLinkButtonLabelForMail() { + protected function getObjectLinkButtonLabelForMail( + PhabricatorLiskDAO $object) { return pht('View Task'); } protected function buildMailBody( PhabricatorLiskDAO $object, array $xactions) { $body = parent::buildMailBody($object, $xactions); if ($this->getIsNewObject()) { $body->addRemarkupSection( pht('TASK DESCRIPTION'), $object->getDescription()); } $body->addLinkSection( pht('TASK DETAIL'), PhabricatorEnv::getProductionURI('/T'.$object->getID())); $board_phids = array(); $type_columns = PhabricatorTransactions::TYPE_COLUMNS; foreach ($xactions as $xaction) { if ($xaction->getTransactionType() == $type_columns) { $moves = $xaction->getNewValue(); foreach ($moves as $move) { $board_phids[] = $move['boardPHID']; } } } if ($board_phids) { $projects = id(new PhabricatorProjectQuery()) ->setViewer($this->requireActor()) ->withPHIDs($board_phids) ->execute(); foreach ($projects as $project) { $body->addLinkSection( pht('WORKBOARD'), PhabricatorEnv::getProductionURI($project->getWorkboardURI())); } } return $body; } protected function shouldPublishFeedStory( PhabricatorLiskDAO $object, array $xactions) { return true; } protected function supportsSearch() { return true; } protected function shouldApplyHeraldRules( PhabricatorLiskDAO $object, array $xactions) { return true; } protected function buildHeraldAdapter( PhabricatorLiskDAO $object, array $xactions) { return id(new HeraldManiphestTaskAdapter()) ->setTask($object); } protected function adjustObjectForPolicyChecks( PhabricatorLiskDAO $object, array $xactions) { $copy = parent::adjustObjectForPolicyChecks($object, $xactions); foreach ($xactions as $xaction) { switch ($xaction->getTransactionType()) { case ManiphestTaskOwnerTransaction::TRANSACTIONTYPE: $copy->setOwnerPHID($xaction->getNewValue()); break; default: break; } } return $copy; } protected function validateAllTransactions( PhabricatorLiskDAO $object, array $xactions) { $errors = parent::validateAllTransactions($object, $xactions); if ($this->moreValidationErrors) { $errors = array_merge($errors, $this->moreValidationErrors); } foreach ($this->getLockValidationErrors($object, $xactions) as $error) { $errors[] = $error; } return $errors; } protected function expandTransactions( PhabricatorLiskDAO $object, array $xactions) { $actor = $this->getActor(); $actor_phid = $actor->getPHID(); $results = parent::expandTransactions($object, $xactions); $is_unassigned = ($object->getOwnerPHID() === null); $any_assign = false; foreach ($xactions as $xaction) { if ($xaction->getTransactionType() == ManiphestTaskOwnerTransaction::TRANSACTIONTYPE) { $any_assign = true; break; } } $is_open = !$object->isClosed(); $new_status = null; foreach ($xactions as $xaction) { switch ($xaction->getTransactionType()) { case ManiphestTaskStatusTransaction::TRANSACTIONTYPE: $new_status = $xaction->getNewValue(); break; } } if ($new_status === null) { $is_closing = false; } else { $is_closing = ManiphestTaskStatus::isClosedStatus($new_status); } // If the task is not assigned, not being assigned, currently open, and // being closed, try to assign the actor as the owner. if ($is_unassigned && !$any_assign && $is_open && $is_closing) { $is_claim = ManiphestTaskStatus::isClaimStatus($new_status); // Don't assign the actor if they aren't a real user. // Don't claim the task if the status is configured to not claim. if ($actor_phid && $is_claim) { $results[] = id(new ManiphestTransaction()) ->setTransactionType(ManiphestTaskOwnerTransaction::TRANSACTIONTYPE) ->setNewValue($actor_phid); } } // Automatically subscribe the author when they create a task. if ($this->getIsNewObject()) { if ($actor_phid) { $results[] = id(new ManiphestTransaction()) ->setTransactionType(PhabricatorTransactions::TYPE_SUBSCRIBERS) ->setNewValue( array( '+' => array($actor_phid => $actor_phid), )); } } return $results; } protected function expandTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { $results = parent::expandTransaction($object, $xaction); $type = $xaction->getTransactionType(); switch ($type) { case PhabricatorTransactions::TYPE_COLUMNS: try { $more_xactions = $this->buildMoveTransaction($object, $xaction); foreach ($more_xactions as $more_xaction) { $results[] = $more_xaction; } } catch (Exception $ex) { $error = new PhabricatorApplicationTransactionValidationError( $type, pht('Invalid'), $ex->getMessage(), $xaction); $this->moreValidationErrors[] = $error; } break; case ManiphestTaskOwnerTransaction::TRANSACTIONTYPE: // If this is a no-op update, don't expand it. $old_value = $object->getOwnerPHID(); $new_value = $xaction->getNewValue(); if ($old_value === $new_value) { break; } // When a task is reassigned, move the old owner to the subscriber // list so they're still in the loop. if ($old_value) { $results[] = id(new ManiphestTransaction()) ->setTransactionType(PhabricatorTransactions::TYPE_SUBSCRIBERS) ->setIgnoreOnNoEffect(true) ->setNewValue( array( '+' => array($old_value => $old_value), )); } break; } return $results; } private function buildMoveTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { $actor = $this->getActor(); $new = $xaction->getNewValue(); if (!is_array($new)) { $this->validateColumnPHID($new); $new = array($new); } $relative_phids = array(); foreach ($new as $key => $value) { if (!is_array($value)) { $this->validateColumnPHID($value); $value = array( 'columnPHID' => $value, ); } PhutilTypeSpec::checkMap( $value, array( 'columnPHID' => 'string', 'beforePHIDs' => 'optional list', 'afterPHIDs' => 'optional list', // Deprecated older variations of "beforePHIDs" and "afterPHIDs". 'beforePHID' => 'optional string', 'afterPHID' => 'optional string', )); $value = $value + array( 'beforePHIDs' => array(), 'afterPHIDs' => array(), ); // Normalize the legacy keys "beforePHID" and "afterPHID" keys to the // modern format. if (!empty($value['afterPHID'])) { if ($value['afterPHIDs']) { throw new Exception( pht( 'Transaction specifies both "afterPHID" and "afterPHIDs". '. 'Specify only "afterPHIDs".')); } $value['afterPHIDs'] = array($value['afterPHID']); unset($value['afterPHID']); } if (isset($value['beforePHID'])) { if ($value['beforePHIDs']) { throw new Exception( pht( 'Transaction specifies both "beforePHID" and "beforePHIDs". '. 'Specify only "beforePHIDs".')); } $value['beforePHIDs'] = array($value['beforePHID']); unset($value['beforePHID']); } foreach ($value['beforePHIDs'] as $phid) { $relative_phids[] = $phid; } foreach ($value['afterPHIDs'] as $phid) { $relative_phids[] = $phid; } $new[$key] = $value; } // We require that objects you specify in "beforePHIDs" or "afterPHIDs" // are real objects which exist and which you have permission to view. // If you provide other objects, we remove them from the specification. if ($relative_phids) { $objects = id(new PhabricatorObjectQuery()) ->setViewer($actor) ->withPHIDs($relative_phids) ->execute(); $objects = mpull($objects, null, 'getPHID'); } else { $objects = array(); } foreach ($new as $key => $value) { $value['afterPHIDs'] = $this->filterValidPHIDs( $value['afterPHIDs'], $objects); $value['beforePHIDs'] = $this->filterValidPHIDs( $value['beforePHIDs'], $objects); $new[$key] = $value; } $column_phids = ipull($new, 'columnPHID'); if ($column_phids) { $columns = id(new PhabricatorProjectColumnQuery()) ->setViewer($actor) ->withPHIDs($column_phids) ->execute(); $columns = mpull($columns, null, 'getPHID'); } else { $columns = array(); } $board_phids = mpull($columns, 'getProjectPHID'); $object_phid = $object->getPHID(); // Note that we may not have an object PHID if we're creating a new // object. $object_phids = array(); if ($object_phid) { $object_phids[] = $object_phid; } if ($object_phids) { $layout_engine = id(new PhabricatorBoardLayoutEngine()) ->setViewer($this->getActor()) ->setBoardPHIDs($board_phids) ->setObjectPHIDs($object_phids) ->setFetchAllBoards(true) ->executeLayout(); } foreach ($new as $key => $spec) { $column_phid = $spec['columnPHID']; $column = idx($columns, $column_phid); if (!$column) { throw new Exception( pht( 'Column move transaction specifies column PHID "%s", but there '. 'is no corresponding column with this PHID.', $column_phid)); } $board_phid = $column->getProjectPHID(); if ($object_phid) { $old_columns = $layout_engine->getObjectColumns( $board_phid, $object_phid); $old_column_phids = mpull($old_columns, 'getPHID'); } else { $old_column_phids = array(); } $spec += array( 'boardPHID' => $board_phid, 'fromColumnPHIDs' => $old_column_phids, ); // Check if the object is already in this column, and isn't being moved. // We can just drop this column change if it has no effect. $from_map = array_fuse($spec['fromColumnPHIDs']); $already_here = isset($from_map[$column_phid]); $is_reordering = ($spec['afterPHIDs'] || $spec['beforePHIDs']); if ($already_here && !$is_reordering) { unset($new[$key]); } else { $new[$key] = $spec; } } $new = array_values($new); $xaction->setNewValue($new); $more = array(); // If we're moving the object into a column and it does not already belong // in the column, add the appropriate board. For normal columns, this // is the board PHID. For proxy columns, it is the proxy PHID, unless the // object is already a member of some descendant of the proxy PHID. // The major case where this can happen is moves via the API, but it also // happens when a user drags a task from the "Backlog" to a milestone // column. if ($object_phid) { $current_phids = PhabricatorEdgeQuery::loadDestinationPHIDs( $object_phid, PhabricatorProjectObjectHasProjectEdgeType::EDGECONST); $current_phids = array_fuse($current_phids); } else { $current_phids = array(); } $add_boards = array(); foreach ($new as $move) { $column_phid = $move['columnPHID']; $board_phid = $move['boardPHID']; $column = $columns[$column_phid]; $proxy_phid = $column->getProxyPHID(); // If this is a normal column, add the board if the object isn't already // associated. if (!$proxy_phid) { if (!isset($current_phids[$board_phid])) { $add_boards[] = $board_phid; } continue; } // If this is a proxy column but the object is already associated with // the proxy board, we don't need to do anything. if (isset($current_phids[$proxy_phid])) { continue; } // If this a proxy column and the object is already associated with some // descendant of the proxy board, we also don't need to do anything. $descendants = id(new PhabricatorProjectQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withAncestorProjectPHIDs(array($proxy_phid)) ->execute(); $found_descendant = false; foreach ($descendants as $descendant) { if (isset($current_phids[$descendant->getPHID()])) { $found_descendant = true; break; } } if ($found_descendant) { continue; } // Otherwise, we're moving the object to a proxy column which it is not // a member of yet, so add an association to the column's proxy board. $add_boards[] = $proxy_phid; } if ($add_boards) { $more[] = id(new ManiphestTransaction()) ->setTransactionType(PhabricatorTransactions::TYPE_EDGE) ->setMetadataValue( 'edge:type', PhabricatorProjectObjectHasProjectEdgeType::EDGECONST) ->setIgnoreOnNoEffect(true) ->setNewValue( array( '+' => array_fuse($add_boards), )); } return $more; } private function applyBoardMove($object, array $move) { $board_phid = $move['boardPHID']; $column_phid = $move['columnPHID']; $before_phids = $move['beforePHIDs']; $after_phids = $move['afterPHIDs']; $object_phid = $object->getPHID(); // We're doing layout with the omnipotent viewer to make sure we don't // remove positions in columns that exist, but which the actual actor // can't see. $omnipotent_viewer = PhabricatorUser::getOmnipotentUser(); $select_phids = array($board_phid); $descendants = id(new PhabricatorProjectQuery()) ->setViewer($omnipotent_viewer) ->withAncestorProjectPHIDs($select_phids) ->execute(); foreach ($descendants as $descendant) { $select_phids[] = $descendant->getPHID(); } $board_tasks = id(new ManiphestTaskQuery()) ->setViewer($omnipotent_viewer) ->withEdgeLogicPHIDs( PhabricatorProjectObjectHasProjectEdgeType::EDGECONST, PhabricatorQueryConstraint::OPERATOR_ANCESTOR, array($select_phids)) ->execute(); $board_tasks = mpull($board_tasks, null, 'getPHID'); $board_tasks[$object_phid] = $object; // Make sure tasks are sorted by ID, so we lay out new positions in // a consistent way. $board_tasks = msort($board_tasks, 'getID'); $object_phids = array_keys($board_tasks); $engine = id(new PhabricatorBoardLayoutEngine()) ->setViewer($omnipotent_viewer) ->setBoardPHIDs(array($board_phid)) ->setObjectPHIDs($object_phids) ->executeLayout(); // TODO: This logic needs to be revised when we legitimately support // multiple column positions. $columns = $engine->getObjectColumns($board_phid, $object_phid); foreach ($columns as $column) { $engine->queueRemovePosition( $board_phid, $column->getPHID(), $object_phid); } $engine->queueAddPosition( $board_phid, $column_phid, $object_phid, $after_phids, $before_phids); $engine->applyPositionUpdates(); } private function validateColumnPHID($value) { if (phid_get_type($value) == PhabricatorProjectColumnPHIDType::TYPECONST) { return; } throw new Exception( pht( 'When moving objects between columns on a board, columns must '. 'be identified by PHIDs. This transaction uses "%s" to identify '. 'a column, but that is not a valid column PHID.', $value)); } private function getLockValidationErrors($object, array $xactions) { $errors = array(); $old_owner = $object->getOwnerPHID(); $old_status = $object->getStatus(); $new_owner = $old_owner; $new_status = $old_status; $owner_xaction = null; $status_xaction = null; foreach ($xactions as $xaction) { switch ($xaction->getTransactionType()) { case ManiphestTaskOwnerTransaction::TRANSACTIONTYPE: $new_owner = $xaction->getNewValue(); $owner_xaction = $xaction; break; case ManiphestTaskStatusTransaction::TRANSACTIONTYPE: $new_status = $xaction->getNewValue(); $status_xaction = $xaction; break; } } $actor_phid = $this->getActingAsPHID(); $was_locked = ManiphestTaskStatus::areEditsLockedInStatus( $old_status); $now_locked = ManiphestTaskStatus::areEditsLockedInStatus( $new_status); if (!$now_locked) { // If we're not ending in an edit-locked status, everything is good. } else if ($new_owner !== null) { // If we ending the edit with some valid owner, this is allowed for // now. We might need to revisit this. } else { // The edits end with the task locked and unowned. No one will be able // to edit it, so we forbid this. We try to be specific about what the // user did wrong. $owner_changed = ($old_owner && !$new_owner); $status_changed = ($was_locked !== $now_locked); $message = null; if ($status_changed && $owner_changed) { $message = pht( 'You can not lock this task and unassign it at the same time '. 'because no one will be able to edit it anymore. Lock the task '. 'or remove the owner, but not both.'); $problem_xaction = $status_xaction; } else if ($status_changed) { $message = pht( 'You can not lock this task because it does not have an owner. '. 'No one would be able to edit the task. Assign the task to an '. 'owner before locking it.'); $problem_xaction = $status_xaction; } else if ($owner_changed) { $message = pht( 'You can not remove the owner of this task because it is locked '. 'and no one would be able to edit the task. Reassign the task or '. 'unlock it before removing the owner.'); $problem_xaction = $owner_xaction; } else { // If the task was already broken, we don't have a transaction to // complain about so just let it through. In theory, this is // impossible since policy rules should kick in before we get here. } if ($message) { $errors[] = new PhabricatorApplicationTransactionValidationError( $problem_xaction->getTransactionType(), pht('Lock Error'), $message, $problem_xaction); } } return $errors; } private function filterValidPHIDs($phid_list, array $object_map) { foreach ($phid_list as $key => $phid) { if (isset($object_map[$phid])) { continue; } unset($phid_list[$key]); } return array_values($phid_list); } } diff --git a/src/applications/transactions/editor/PhabricatorApplicationTransactionEditor.php b/src/applications/transactions/editor/PhabricatorApplicationTransactionEditor.php index bcce014669..da5e4d3634 100644 --- a/src/applications/transactions/editor/PhabricatorApplicationTransactionEditor.php +++ b/src/applications/transactions/editor/PhabricatorApplicationTransactionEditor.php @@ -1,5359 +1,5381 @@ actingAsPHID = $acting_as_phid; return $this; } public function getActingAsPHID() { if ($this->actingAsPHID) { return $this->actingAsPHID; } return $this->getActor()->getPHID(); } /** * When the editor tries to apply transactions that have no effect, should * it raise an exception (default) or drop them and continue? * * Generally, you will set this flag for edits coming from "Edit" interfaces, * and leave it cleared for edits coming from "Comment" interfaces, so the * user will get a useful error if they try to submit a comment that does * nothing (e.g., empty comment with a status change that has already been * performed by another user). * * @param bool True to drop transactions without effect and continue. * @return this */ public function setContinueOnNoEffect($continue) { $this->continueOnNoEffect = $continue; return $this; } public function getContinueOnNoEffect() { return $this->continueOnNoEffect; } /** * When the editor tries to apply transactions which don't populate all of * an object's required fields, should it raise an exception (default) or * drop them and continue? * * For example, if a user adds a new required custom field (like "Severity") * to a task, all existing tasks won't have it populated. When users * manually edit existing tasks, it's usually desirable to have them provide * a severity. However, other operations (like batch editing just the * owner of a task) will fail by default. * * By setting this flag for edit operations which apply to specific fields * (like the priority, batch, and merge editors in Maniphest), these * operations can continue to function even if an object is outdated. * * @param bool True to continue when transactions don't completely satisfy * all required fields. * @return this */ public function setContinueOnMissingFields($continue_on_missing_fields) { $this->continueOnMissingFields = $continue_on_missing_fields; return $this; } public function getContinueOnMissingFields() { return $this->continueOnMissingFields; } /** * Not strictly necessary, but reply handlers ideally set this value to * make email threading work better. */ public function setParentMessageID($parent_message_id) { $this->parentMessageID = $parent_message_id; return $this; } public function getParentMessageID() { return $this->parentMessageID; } public function getIsNewObject() { return $this->isNewObject; } public function getMentionedPHIDs() { return $this->mentionedPHIDs; } public function setIsPreview($is_preview) { $this->isPreview = $is_preview; return $this; } public function getIsPreview() { return $this->isPreview; } public function setIsSilent($silent) { $this->silent = $silent; return $this; } public function getIsSilent() { return $this->silent; } public function getMustEncrypt() { return $this->mustEncrypt; } public function getHeraldRuleMonograms() { // Convert the stored "<123>, <456>" string into a list: "H123", "H456". $list = $this->heraldHeader; $list = preg_split('/[, ]+/', $list); foreach ($list as $key => $item) { $item = trim($item, '<>'); if (!is_numeric($item)) { unset($list[$key]); continue; } $list[$key] = 'H'.$item; } return $list; } public function setIsInverseEdgeEditor($is_inverse_edge_editor) { $this->isInverseEdgeEditor = $is_inverse_edge_editor; return $this; } public function getIsInverseEdgeEditor() { return $this->isInverseEdgeEditor; } public function setIsHeraldEditor($is_herald_editor) { $this->isHeraldEditor = $is_herald_editor; return $this; } public function getIsHeraldEditor() { return $this->isHeraldEditor; } public function addUnmentionablePHIDs(array $phids) { foreach ($phids as $phid) { $this->unmentionablePHIDMap[$phid] = true; } return $this; } private function getUnmentionablePHIDMap() { return $this->unmentionablePHIDMap; } protected function shouldEnableMentions( PhabricatorLiskDAO $object, array $xactions) { return true; } public function setApplicationEmail( PhabricatorMetaMTAApplicationEmail $email) { $this->applicationEmail = $email; return $this; } public function getApplicationEmail() { return $this->applicationEmail; } public function setRaiseWarnings($raise_warnings) { $this->raiseWarnings = $raise_warnings; return $this; } public function getRaiseWarnings() { return $this->raiseWarnings; } public function setShouldRequireMFA($should_require_mfa) { if ($this->hasRequiredMFA) { throw new Exception( pht( 'Call to setShouldRequireMFA() is too late: this Editor has already '. 'checked for MFA requirements.')); } $this->shouldRequireMFA = $should_require_mfa; return $this; } public function getShouldRequireMFA() { return $this->shouldRequireMFA; } public function getTransactionTypesForObject($object) { $old = $this->object; try { $this->object = $object; $result = $this->getTransactionTypes(); $this->object = $old; } catch (Exception $ex) { $this->object = $old; throw $ex; } return $result; } public function getTransactionTypes() { $types = array(); $types[] = PhabricatorTransactions::TYPE_CREATE; $types[] = PhabricatorTransactions::TYPE_HISTORY; if ($this->object instanceof PhabricatorEditEngineSubtypeInterface) { $types[] = PhabricatorTransactions::TYPE_SUBTYPE; } if ($this->object instanceof PhabricatorSubscribableInterface) { $types[] = PhabricatorTransactions::TYPE_SUBSCRIBERS; } if ($this->object instanceof PhabricatorCustomFieldInterface) { $types[] = PhabricatorTransactions::TYPE_CUSTOMFIELD; } if ($this->object instanceof PhabricatorTokenReceiverInterface) { $types[] = PhabricatorTransactions::TYPE_TOKEN; } if ($this->object instanceof PhabricatorProjectInterface || $this->object instanceof PhabricatorMentionableInterface) { $types[] = PhabricatorTransactions::TYPE_EDGE; } if ($this->object instanceof PhabricatorSpacesInterface) { $types[] = PhabricatorTransactions::TYPE_SPACE; } $types[] = PhabricatorTransactions::TYPE_MFA; $template = $this->object->getApplicationTransactionTemplate(); if ($template instanceof PhabricatorModularTransaction) { $xtypes = $template->newModularTransactionTypes(); foreach ($xtypes as $xtype) { $types[] = $xtype->getTransactionTypeConstant(); } } if ($template) { $comment = $template->getApplicationTransactionCommentObject(); if ($comment) { $types[] = PhabricatorTransactions::TYPE_COMMENT; } } return $types; } private function adjustTransactionValues( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { if ($xaction->shouldGenerateOldValue()) { $old = $this->getTransactionOldValue($object, $xaction); $xaction->setOldValue($old); } $new = $this->getTransactionNewValue($object, $xaction); $xaction->setNewValue($new); } private function getTransactionOldValue( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { $type = $xaction->getTransactionType(); $xtype = $this->getModularTransactionType($type); if ($xtype) { $xtype = clone $xtype; $xtype->setStorage($xaction); return $xtype->generateOldValue($object); } switch ($type) { case PhabricatorTransactions::TYPE_CREATE: case PhabricatorTransactions::TYPE_HISTORY: return null; case PhabricatorTransactions::TYPE_SUBTYPE: return $object->getEditEngineSubtype(); case PhabricatorTransactions::TYPE_MFA: return null; case PhabricatorTransactions::TYPE_SUBSCRIBERS: return array_values($this->subscribers); case PhabricatorTransactions::TYPE_VIEW_POLICY: if ($this->getIsNewObject()) { return null; } return $object->getViewPolicy(); case PhabricatorTransactions::TYPE_EDIT_POLICY: if ($this->getIsNewObject()) { return null; } return $object->getEditPolicy(); case PhabricatorTransactions::TYPE_JOIN_POLICY: if ($this->getIsNewObject()) { return null; } return $object->getJoinPolicy(); case PhabricatorTransactions::TYPE_SPACE: if ($this->getIsNewObject()) { return null; } $space_phid = $object->getSpacePHID(); if ($space_phid === null) { $default_space = PhabricatorSpacesNamespaceQuery::getDefaultSpace(); if ($default_space) { $space_phid = $default_space->getPHID(); } } return $space_phid; case PhabricatorTransactions::TYPE_EDGE: $edge_type = $xaction->getMetadataValue('edge:type'); if (!$edge_type) { throw new Exception( pht( "Edge transaction has no '%s'!", 'edge:type')); } // See T13082. If this is an inverse edit, the parent editor has // already populated the transaction values correctly. if ($this->getIsInverseEdgeEditor()) { return $xaction->getOldValue(); } $old_edges = array(); if ($object->getPHID()) { $edge_src = $object->getPHID(); $old_edges = id(new PhabricatorEdgeQuery()) ->withSourcePHIDs(array($edge_src)) ->withEdgeTypes(array($edge_type)) ->needEdgeData(true) ->execute(); $old_edges = $old_edges[$edge_src][$edge_type]; } return $old_edges; case PhabricatorTransactions::TYPE_CUSTOMFIELD: // NOTE: Custom fields have their old value pre-populated when they are // built by PhabricatorCustomFieldList. return $xaction->getOldValue(); case PhabricatorTransactions::TYPE_COMMENT: return null; default: return $this->getCustomTransactionOldValue($object, $xaction); } } private function getTransactionNewValue( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { $type = $xaction->getTransactionType(); $xtype = $this->getModularTransactionType($type); if ($xtype) { $xtype = clone $xtype; $xtype->setStorage($xaction); return $xtype->generateNewValue($object, $xaction->getNewValue()); } switch ($type) { case PhabricatorTransactions::TYPE_CREATE: return null; case PhabricatorTransactions::TYPE_SUBSCRIBERS: return $this->getPHIDTransactionNewValue($xaction); case PhabricatorTransactions::TYPE_VIEW_POLICY: case PhabricatorTransactions::TYPE_EDIT_POLICY: case PhabricatorTransactions::TYPE_JOIN_POLICY: case PhabricatorTransactions::TYPE_TOKEN: case PhabricatorTransactions::TYPE_INLINESTATE: case PhabricatorTransactions::TYPE_SUBTYPE: case PhabricatorTransactions::TYPE_HISTORY: return $xaction->getNewValue(); case PhabricatorTransactions::TYPE_MFA: return true; case PhabricatorTransactions::TYPE_SPACE: $space_phid = $xaction->getNewValue(); if (!strlen($space_phid)) { // If an install has no Spaces or the Spaces controls are not visible // to the viewer, we might end up with the empty string here instead // of a strict `null`, because some controller just used `getStr()` // to read the space PHID from the request. // Just make this work like callers might reasonably expect so we // don't need to handle this specially in every EditController. return $this->getActor()->getDefaultSpacePHID(); } else { return $space_phid; } case PhabricatorTransactions::TYPE_EDGE: // See T13082. If this is an inverse edit, the parent editor has // already populated appropriate transaction values. if ($this->getIsInverseEdgeEditor()) { return $xaction->getNewValue(); } $new_value = $this->getEdgeTransactionNewValue($xaction); $edge_type = $xaction->getMetadataValue('edge:type'); $type_project = PhabricatorProjectObjectHasProjectEdgeType::EDGECONST; if ($edge_type == $type_project) { $new_value = $this->applyProjectConflictRules($new_value); } return $new_value; case PhabricatorTransactions::TYPE_CUSTOMFIELD: $field = $this->getCustomFieldForTransaction($object, $xaction); return $field->getNewValueFromApplicationTransactions($xaction); case PhabricatorTransactions::TYPE_COMMENT: return null; default: return $this->getCustomTransactionNewValue($object, $xaction); } } protected function getCustomTransactionOldValue( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { throw new Exception(pht('Capability not supported!')); } protected function getCustomTransactionNewValue( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { throw new Exception(pht('Capability not supported!')); } protected function transactionHasEffect( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case PhabricatorTransactions::TYPE_CREATE: case PhabricatorTransactions::TYPE_HISTORY: return true; case PhabricatorTransactions::TYPE_CUSTOMFIELD: $field = $this->getCustomFieldForTransaction($object, $xaction); return $field->getApplicationTransactionHasEffect($xaction); case PhabricatorTransactions::TYPE_EDGE: // A straight value comparison here doesn't always get the right // result, because newly added edges aren't fully populated. Instead, // compare the changes in a more granular way. $old = $xaction->getOldValue(); $new = $xaction->getNewValue(); $old_dst = array_keys($old); $new_dst = array_keys($new); // NOTE: For now, we don't consider edge reordering to be a change. // We have very few order-dependent edges and effectively no order // oriented UI. This might change in the future. sort($old_dst); sort($new_dst); if ($old_dst !== $new_dst) { // We've added or removed edges, so this transaction definitely // has an effect. return true; } // We haven't added or removed edges, but we might have changed // edge data. foreach ($old as $key => $old_value) { $new_value = $new[$key]; if ($old_value['data'] !== $new_value['data']) { return true; } } return false; } $type = $xaction->getTransactionType(); $xtype = $this->getModularTransactionType($type); if ($xtype) { return $xtype->getTransactionHasEffect( $object, $xaction->getOldValue(), $xaction->getNewValue()); } if ($xaction->hasComment()) { return true; } return ($xaction->getOldValue() !== $xaction->getNewValue()); } protected function shouldApplyInitialEffects( PhabricatorLiskDAO $object, array $xactions) { return false; } protected function applyInitialEffects( PhabricatorLiskDAO $object, array $xactions) { throw new PhutilMethodNotImplementedException(); } private function applyInternalEffects( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { $type = $xaction->getTransactionType(); $xtype = $this->getModularTransactionType($type); if ($xtype) { $xtype = clone $xtype; $xtype->setStorage($xaction); return $xtype->applyInternalEffects($object, $xaction->getNewValue()); } switch ($type) { case PhabricatorTransactions::TYPE_CUSTOMFIELD: $field = $this->getCustomFieldForTransaction($object, $xaction); return $field->applyApplicationTransactionInternalEffects($xaction); case PhabricatorTransactions::TYPE_CREATE: case PhabricatorTransactions::TYPE_HISTORY: case PhabricatorTransactions::TYPE_SUBTYPE: case PhabricatorTransactions::TYPE_MFA: case PhabricatorTransactions::TYPE_TOKEN: case PhabricatorTransactions::TYPE_VIEW_POLICY: case PhabricatorTransactions::TYPE_EDIT_POLICY: case PhabricatorTransactions::TYPE_JOIN_POLICY: case PhabricatorTransactions::TYPE_SUBSCRIBERS: case PhabricatorTransactions::TYPE_INLINESTATE: case PhabricatorTransactions::TYPE_EDGE: case PhabricatorTransactions::TYPE_SPACE: case PhabricatorTransactions::TYPE_COMMENT: return $this->applyBuiltinInternalTransaction($object, $xaction); } return $this->applyCustomInternalTransaction($object, $xaction); } private function applyExternalEffects( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { $type = $xaction->getTransactionType(); $xtype = $this->getModularTransactionType($type); if ($xtype) { $xtype = clone $xtype; $xtype->setStorage($xaction); return $xtype->applyExternalEffects($object, $xaction->getNewValue()); } switch ($type) { case PhabricatorTransactions::TYPE_SUBSCRIBERS: $subeditor = id(new PhabricatorSubscriptionsEditor()) ->setObject($object) ->setActor($this->requireActor()); $old_map = array_fuse($xaction->getOldValue()); $new_map = array_fuse($xaction->getNewValue()); $subeditor->unsubscribe( array_keys( array_diff_key($old_map, $new_map))); $subeditor->subscribeExplicit( array_keys( array_diff_key($new_map, $old_map))); $subeditor->save(); // for the rest of these edits, subscribers should include those just // added as well as those just removed. $subscribers = array_unique(array_merge( $this->subscribers, $xaction->getOldValue(), $xaction->getNewValue())); $this->subscribers = $subscribers; return $this->applyBuiltinExternalTransaction($object, $xaction); case PhabricatorTransactions::TYPE_CUSTOMFIELD: $field = $this->getCustomFieldForTransaction($object, $xaction); return $field->applyApplicationTransactionExternalEffects($xaction); case PhabricatorTransactions::TYPE_CREATE: case PhabricatorTransactions::TYPE_HISTORY: case PhabricatorTransactions::TYPE_SUBTYPE: case PhabricatorTransactions::TYPE_MFA: case PhabricatorTransactions::TYPE_EDGE: case PhabricatorTransactions::TYPE_TOKEN: case PhabricatorTransactions::TYPE_VIEW_POLICY: case PhabricatorTransactions::TYPE_EDIT_POLICY: case PhabricatorTransactions::TYPE_JOIN_POLICY: case PhabricatorTransactions::TYPE_INLINESTATE: case PhabricatorTransactions::TYPE_SPACE: case PhabricatorTransactions::TYPE_COMMENT: return $this->applyBuiltinExternalTransaction($object, $xaction); } return $this->applyCustomExternalTransaction($object, $xaction); } protected function applyCustomInternalTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { $type = $xaction->getTransactionType(); throw new Exception( pht( "Transaction type '%s' is missing an internal apply implementation!", $type)); } protected function applyCustomExternalTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { $type = $xaction->getTransactionType(); throw new Exception( pht( "Transaction type '%s' is missing an external apply implementation!", $type)); } /** * @{class:PhabricatorTransactions} provides many built-in transactions * which should not require much - if any - code in specific applications. * * This method is a hook for the exceedingly-rare cases where you may need * to do **additional** work for built-in transactions. Developers should * extend this method, making sure to return the parent implementation * regardless of handling any transactions. * * See also @{method:applyBuiltinExternalTransaction}. */ protected function applyBuiltinInternalTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case PhabricatorTransactions::TYPE_VIEW_POLICY: $object->setViewPolicy($xaction->getNewValue()); break; case PhabricatorTransactions::TYPE_EDIT_POLICY: $object->setEditPolicy($xaction->getNewValue()); break; case PhabricatorTransactions::TYPE_JOIN_POLICY: $object->setJoinPolicy($xaction->getNewValue()); break; case PhabricatorTransactions::TYPE_SPACE: $object->setSpacePHID($xaction->getNewValue()); break; case PhabricatorTransactions::TYPE_SUBTYPE: $object->setEditEngineSubtype($xaction->getNewValue()); break; } } /** * See @{method::applyBuiltinInternalTransaction}. */ protected function applyBuiltinExternalTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case PhabricatorTransactions::TYPE_EDGE: if ($this->getIsInverseEdgeEditor()) { // If we're writing an inverse edge transaction, don't actually // do anything. The initiating editor on the other side of the // transaction will take care of the edge writes. break; } $old = $xaction->getOldValue(); $new = $xaction->getNewValue(); $src = $object->getPHID(); $const = $xaction->getMetadataValue('edge:type'); foreach ($new as $dst_phid => $edge) { $new[$dst_phid]['src'] = $src; } $editor = new PhabricatorEdgeEditor(); foreach ($old as $dst_phid => $edge) { if (!empty($new[$dst_phid])) { if ($old[$dst_phid]['data'] === $new[$dst_phid]['data']) { continue; } } $editor->removeEdge($src, $const, $dst_phid); } foreach ($new as $dst_phid => $edge) { if (!empty($old[$dst_phid])) { if ($old[$dst_phid]['data'] === $new[$dst_phid]['data']) { continue; } } $data = array( 'data' => $edge['data'], ); $editor->addEdge($src, $const, $dst_phid, $data); } $editor->save(); $this->updateWorkboardColumns($object, $const, $old, $new); break; case PhabricatorTransactions::TYPE_VIEW_POLICY: case PhabricatorTransactions::TYPE_SPACE: $this->scrambleFileSecrets($object); break; case PhabricatorTransactions::TYPE_HISTORY: $this->sendHistory = true; break; } } /** * Fill in a transaction's common values, like author and content source. */ protected function populateTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { $actor = $this->getActor(); // TODO: This needs to be more sophisticated once we have meta-policies. $xaction->setViewPolicy(PhabricatorPolicies::POLICY_PUBLIC); if ($actor->isOmnipotent()) { $xaction->setEditPolicy(PhabricatorPolicies::POLICY_NOONE); } else { $xaction->setEditPolicy($this->getActingAsPHID()); } // If the transaction already has an explicit author PHID, allow it to // stand. This is used by applications like Owners that hook into the // post-apply change pipeline. if (!$xaction->getAuthorPHID()) { $xaction->setAuthorPHID($this->getActingAsPHID()); } $xaction->setContentSource($this->getContentSource()); $xaction->attachViewer($actor); $xaction->attachObject($object); if ($object->getPHID()) { $xaction->setObjectPHID($object->getPHID()); } if ($this->getIsSilent()) { $xaction->setIsSilentTransaction(true); } return $xaction; } protected function didApplyInternalEffects( PhabricatorLiskDAO $object, array $xactions) { return $xactions; } protected function applyFinalEffects( PhabricatorLiskDAO $object, array $xactions) { return $xactions; } final protected function didCommitTransactions( PhabricatorLiskDAO $object, array $xactions) { foreach ($xactions as $xaction) { $type = $xaction->getTransactionType(); // See T13082. When we're writing edges that imply corresponding inverse // transactions, apply those inverse transactions now. We have to wait // until the object we're editing (with this editor) has committed its // transactions to do this. If we don't, the inverse editor may race, // build a mail before we actually commit this object, and render "alice // added an edge: Unknown Object". if ($type === PhabricatorTransactions::TYPE_EDGE) { // Don't do anything if we're already an inverse edge editor. if ($this->getIsInverseEdgeEditor()) { continue; } $edge_const = $xaction->getMetadataValue('edge:type'); $edge_type = PhabricatorEdgeType::getByConstant($edge_const); if ($edge_type->shouldWriteInverseTransactions()) { $this->applyInverseEdgeTransactions( $object, $xaction, $edge_type->getInverseEdgeConstant()); } continue; } $xtype = $this->getModularTransactionType($type); if (!$xtype) { continue; } $xtype = clone $xtype; $xtype->setStorage($xaction); $xtype->didCommitTransaction($object, $xaction->getNewValue()); } } public function setContentSource(PhabricatorContentSource $content_source) { $this->contentSource = $content_source; return $this; } public function setContentSourceFromRequest(AphrontRequest $request) { $this->setRequest($request); return $this->setContentSource( PhabricatorContentSource::newFromRequest($request)); } public function getContentSource() { return $this->contentSource; } public function setRequest(AphrontRequest $request) { $this->request = $request; return $this; } public function getRequest() { return $this->request; } public function setCancelURI($cancel_uri) { $this->cancelURI = $cancel_uri; return $this; } public function getCancelURI() { return $this->cancelURI; } protected function getTransactionGroupID() { if ($this->transactionGroupID === null) { $this->transactionGroupID = Filesystem::readRandomCharacters(32); } return $this->transactionGroupID; } final public function applyTransactions( PhabricatorLiskDAO $object, array $xactions) { $is_new = ($object->getID() === null); $this->isNewObject = $is_new; $is_preview = $this->getIsPreview(); $read_locking = false; $transaction_open = false; // If we're attempting to apply transactions, lock and reload the object // before we go anywhere. If we don't do this at the very beginning, we // may be looking at an older version of the object when we populate and // filter the transactions. See PHI1165 for an example. if (!$is_preview) { if (!$is_new) { $this->buildOldRecipientLists($object, $xactions); $object->openTransaction(); $transaction_open = true; $object->beginReadLocking(); $read_locking = true; $object->reload(); } } try { $this->object = $object; $this->xactions = $xactions; $this->validateEditParameters($object, $xactions); $xactions = $this->newMFATransactions($object, $xactions); $actor = $this->requireActor(); // NOTE: Some transaction expansion requires that the edited object be // attached. foreach ($xactions as $xaction) { $xaction->attachObject($object); $xaction->attachViewer($actor); } $xactions = $this->expandTransactions($object, $xactions); $xactions = $this->expandSupportTransactions($object, $xactions); $xactions = $this->combineTransactions($xactions); foreach ($xactions as $xaction) { $xaction = $this->populateTransaction($object, $xaction); } if (!$is_preview) { $errors = array(); $type_map = mgroup($xactions, 'getTransactionType'); foreach ($this->getTransactionTypes() as $type) { $type_xactions = idx($type_map, $type, array()); $errors[] = $this->validateTransaction( $object, $type, $type_xactions); } $errors[] = $this->validateAllTransactions($object, $xactions); $errors[] = $this->validateTransactionsWithExtensions( $object, $xactions); $errors = array_mergev($errors); $continue_on_missing = $this->getContinueOnMissingFields(); foreach ($errors as $key => $error) { if ($continue_on_missing && $error->getIsMissingFieldError()) { unset($errors[$key]); } } if ($errors) { throw new PhabricatorApplicationTransactionValidationException( $errors); } if ($this->raiseWarnings) { $warnings = array(); foreach ($xactions as $xaction) { if ($this->hasWarnings($object, $xaction)) { $warnings[] = $xaction; } } if ($warnings) { throw new PhabricatorApplicationTransactionWarningException( $warnings); } } } foreach ($xactions as $xaction) { $this->adjustTransactionValues($object, $xaction); } // Now that we've merged and combined transactions, check for required // capabilities. Note that we're doing this before filtering // transactions: if you try to apply an edit which you do not have // permission to apply, we want to give you a permissions error even // if the edit would have no effect. $this->applyCapabilityChecks($object, $xactions); $xactions = $this->filterTransactions($object, $xactions); if (!$is_preview) { $this->hasRequiredMFA = true; if ($this->getShouldRequireMFA()) { $this->requireMFA($object, $xactions); } if ($this->shouldApplyInitialEffects($object, $xactions)) { if (!$transaction_open) { $object->openTransaction(); $transaction_open = true; } } } if ($this->shouldApplyInitialEffects($object, $xactions)) { $this->applyInitialEffects($object, $xactions); } // TODO: Once everything is on EditEngine, just use getIsNewObject() to // figure this out instead. $mark_as_create = false; $create_type = PhabricatorTransactions::TYPE_CREATE; foreach ($xactions as $xaction) { if ($xaction->getTransactionType() == $create_type) { $mark_as_create = true; } } if ($mark_as_create) { foreach ($xactions as $xaction) { $xaction->setIsCreateTransaction(true); } } $xactions = $this->sortTransactions($xactions); $file_phids = $this->extractFilePHIDs($object, $xactions); if ($is_preview) { $this->loadHandles($xactions); return $xactions; } $comment_editor = id(new PhabricatorApplicationTransactionCommentEditor()) ->setActor($actor) ->setActingAsPHID($this->getActingAsPHID()) ->setContentSource($this->getContentSource()) ->setIsNewComment(true); if (!$transaction_open) { $object->openTransaction(); $transaction_open = true; } // We can technically test any object for CAN_INTERACT, but we can // run into some issues in doing so (for example, in project unit tests). // For now, only test for CAN_INTERACT if the object is explicitly a // lockable object. $was_locked = false; if ($object instanceof PhabricatorEditEngineLockableInterface) { $was_locked = !PhabricatorPolicyFilter::canInteract($actor, $object); } foreach ($xactions as $xaction) { $this->applyInternalEffects($object, $xaction); } $xactions = $this->didApplyInternalEffects($object, $xactions); try { $object->save(); } catch (AphrontDuplicateKeyQueryException $ex) { // This callback has an opportunity to throw a better exception, // so execution may end here. $this->didCatchDuplicateKeyException($object, $xactions, $ex); throw $ex; } $group_id = $this->getTransactionGroupID(); foreach ($xactions as $xaction) { if ($was_locked) { $is_override = $this->isLockOverrideTransaction($xaction); if ($is_override) { $xaction->setIsLockOverrideTransaction(true); } } $xaction->setObjectPHID($object->getPHID()); $xaction->setTransactionGroupID($group_id); if ($xaction->getComment()) { $xaction->setPHID($xaction->generatePHID()); $comment_editor->applyEdit($xaction, $xaction->getComment()); } else { // TODO: This is a transitional hack to let us migrate edge // transactions to a more efficient storage format. For now, we're // going to write a new slim format to the database but keep the old // bulky format on the objects so we don't have to upgrade all the // edit logic to the new format yet. See T13051. $edge_type = PhabricatorTransactions::TYPE_EDGE; if ($xaction->getTransactionType() == $edge_type) { $bulky_old = $xaction->getOldValue(); $bulky_new = $xaction->getNewValue(); $record = PhabricatorEdgeChangeRecord::newFromTransaction($xaction); $slim_old = $record->getModernOldEdgeTransactionData(); $slim_new = $record->getModernNewEdgeTransactionData(); $xaction->setOldValue($slim_old); $xaction->setNewValue($slim_new); $xaction->save(); $xaction->setOldValue($bulky_old); $xaction->setNewValue($bulky_new); } else { $xaction->save(); } } } if ($file_phids) { $this->attachFiles($object, $file_phids); } foreach ($xactions as $xaction) { $this->applyExternalEffects($object, $xaction); } $xactions = $this->applyFinalEffects($object, $xactions); if ($read_locking) { $object->endReadLocking(); $read_locking = false; } if ($transaction_open) { $object->saveTransaction(); $transaction_open = false; } $this->didCommitTransactions($object, $xactions); } catch (Exception $ex) { if ($read_locking) { $object->endReadLocking(); $read_locking = false; } if ($transaction_open) { $object->killTransaction(); $transaction_open = false; } throw $ex; } // If we need to perform cache engine updates, execute them now. id(new PhabricatorCacheEngine()) ->updateObject($object); // Now that we've completely applied the core transaction set, try to apply // Herald rules. Herald rules are allowed to either take direct actions on // the database (like writing flags), or take indirect actions (like saving // some targets for CC when we generate mail a little later), or return // transactions which we'll apply normally using another Editor. // First, check if *this* is a sub-editor which is itself applying Herald // rules: if it is, stop working and return so we don't descend into // madness. // Otherwise, we're not a Herald editor, so process Herald rules (possibly // using a Herald editor to apply resulting transactions) and then send out // mail, notifications, and feed updates about everything. if ($this->getIsHeraldEditor()) { // We are the Herald editor, so stop work here and return the updated // transactions. return $xactions; } else if ($this->getIsInverseEdgeEditor()) { // Do not run Herald if we're just recording that this object was // mentioned elsewhere. This tends to create Herald side effects which // feel arbitrary, and can really slow down edits which mention a large // number of other objects. See T13114. } else if ($this->shouldApplyHeraldRules($object, $xactions)) { // We are not the Herald editor, so try to apply Herald rules. $herald_xactions = $this->applyHeraldRules($object, $xactions); if ($herald_xactions) { $xscript_id = $this->getHeraldTranscript()->getID(); foreach ($herald_xactions as $herald_xaction) { // Don't set a transcript ID if this is a transaction from another // application or source, like Owners. if ($herald_xaction->getAuthorPHID()) { continue; } $herald_xaction->setMetadataValue('herald:transcriptID', $xscript_id); } // NOTE: We're acting as the omnipotent user because rules deal with // their own policy issues. We use a synthetic author PHID (the // Herald application) as the author of record, so that transactions // will render in a reasonable way ("Herald assigned this task ..."). $herald_actor = PhabricatorUser::getOmnipotentUser(); $herald_phid = id(new PhabricatorHeraldApplication())->getPHID(); // TODO: It would be nice to give transactions a more specific source // which points at the rule which generated them. You can figure this // out from transcripts, but it would be cleaner if you didn't have to. $herald_source = PhabricatorContentSource::newForSource( PhabricatorHeraldContentSource::SOURCECONST); $herald_editor = $this->newEditorCopy() ->setContinueOnNoEffect(true) ->setContinueOnMissingFields(true) ->setIsHeraldEditor(true) ->setActor($herald_actor) ->setActingAsPHID($herald_phid) ->setContentSource($herald_source); $herald_xactions = $herald_editor->applyTransactions( $object, $herald_xactions); // Merge the new transactions into the transaction list: we want to // send email and publish feed stories about them, too. $xactions = array_merge($xactions, $herald_xactions); } // If Herald did not generate transactions, we may still need to handle // "Send an Email" rules. $adapter = $this->getHeraldAdapter(); $this->heraldEmailPHIDs = $adapter->getEmailPHIDs(); $this->heraldForcedEmailPHIDs = $adapter->getForcedEmailPHIDs(); $this->webhookMap = $adapter->getWebhookMap(); } $xactions = $this->didApplyTransactions($object, $xactions); if ($object instanceof PhabricatorCustomFieldInterface) { // Maybe this makes more sense to move into the search index itself? For // now I'm putting it here since I think we might end up with things that // need it to be up to date once the next page loads, but if we don't go // there we could move it into search once search moves to the daemons. // It now happens in the search indexer as well, but the search indexer is // always daemonized, so the logic above still potentially holds. We could // possibly get rid of this. The major motivation for putting it in the // indexer was to enable reindexing to work. $fields = PhabricatorCustomField::getObjectFields( $object, PhabricatorCustomField::ROLE_APPLICATIONSEARCH); $fields->readFieldsFromStorage($object); $fields->rebuildIndexes($object); } $herald_xscript = $this->getHeraldTranscript(); if ($herald_xscript) { $herald_header = $herald_xscript->getXHeraldRulesHeader(); $herald_header = HeraldTranscript::saveXHeraldRulesHeader( $object->getPHID(), $herald_header); } else { $herald_header = HeraldTranscript::loadXHeraldRulesHeader( $object->getPHID()); } $this->heraldHeader = $herald_header; // See PHI1134. If we're a subeditor, we don't publish information about // the edit yet. Our parent editor still needs to finish applying // transactions and execute Herald, which may change the information we // publish. // For example, Herald actions may change the parent object's title or // visibility, or Herald may apply rules like "Must Encrypt" that affect // email. // Once the parent finishes work, it will queue its own publish step and // then queue publish steps for its children. $this->publishableObject = $object; $this->publishableTransactions = $xactions; if (!$this->parentEditor) { $this->queuePublishing(); } return $xactions; } final private function queuePublishing() { $object = $this->publishableObject; $xactions = $this->publishableTransactions; if (!$object) { throw new Exception( pht( 'Editor method "queuePublishing()" was called, but no publishable '. 'object is present. This Editor is not ready to publish.')); } // We're going to compute some of the data we'll use to publish these // transactions here, before queueing a worker. // // Primarily, this is more correct: we want to publish the object as it // exists right now. The worker may not execute for some time, and we want // to use the current To/CC list, not respect any changes which may occur // between now and when the worker executes. // // As a secondary benefit, this tends to reduce the amount of state that // Editors need to pass into workers. $object = $this->willPublish($object, $xactions); if (!$this->getIsSilent()) { if ($this->shouldSendMail($object, $xactions)) { $this->mailShouldSend = true; $this->mailToPHIDs = $this->getMailTo($object); $this->mailCCPHIDs = $this->getMailCC($object); $this->mailUnexpandablePHIDs = $this->newMailUnexpandablePHIDs($object); // Add any recipients who were previously on the notification list // but were removed by this change. $this->applyOldRecipientLists(); if ($object instanceof PhabricatorSubscribableInterface) { $this->mailMutedPHIDs = PhabricatorEdgeQuery::loadDestinationPHIDs( $object->getPHID(), PhabricatorMutedByEdgeType::EDGECONST); } else { $this->mailMutedPHIDs = array(); } $mail_xactions = $this->getTransactionsForMail($object, $xactions); $stamps = $this->newMailStamps($object, $xactions); foreach ($stamps as $stamp) { $this->mailStamps[] = $stamp->toDictionary(); } } if ($this->shouldPublishFeedStory($object, $xactions)) { $this->feedShouldPublish = true; $this->feedRelatedPHIDs = $this->getFeedRelatedPHIDs( $object, $xactions); $this->feedNotifyPHIDs = $this->getFeedNotifyPHIDs( $object, $xactions); } } PhabricatorWorker::scheduleTask( 'PhabricatorApplicationTransactionPublishWorker', array( 'objectPHID' => $object->getPHID(), 'actorPHID' => $this->getActingAsPHID(), 'xactionPHIDs' => mpull($xactions, 'getPHID'), 'state' => $this->getWorkerState(), ), array( 'objectPHID' => $object->getPHID(), 'priority' => PhabricatorWorker::PRIORITY_ALERTS, )); foreach ($this->subEditors as $sub_editor) { $sub_editor->queuePublishing(); } $this->flushTransactionQueue($object); } protected function didCatchDuplicateKeyException( PhabricatorLiskDAO $object, array $xactions, Exception $ex) { return; } public function publishTransactions( PhabricatorLiskDAO $object, array $xactions) { $this->object = $object; $this->xactions = $xactions; // Hook for edges or other properties that may need (re-)loading $object = $this->willPublish($object, $xactions); // The object might have changed, so reassign it. $this->object = $object; $messages = array(); if ($this->mailShouldSend) { $messages = $this->buildMail($object, $xactions); } if ($this->supportsSearch()) { PhabricatorSearchWorker::queueDocumentForIndexing( $object->getPHID(), array( 'transactionPHIDs' => mpull($xactions, 'getPHID'), )); } if ($this->feedShouldPublish) { $mailed = array(); foreach ($messages as $mail) { foreach ($mail->buildRecipientList() as $phid) { $mailed[$phid] = $phid; } } $this->publishFeedStory($object, $xactions, $mailed); } if ($this->sendHistory) { $history_mail = $this->buildHistoryMail($object); if ($history_mail) { $messages[] = $history_mail; } } // NOTE: This actually sends the mail. We do this last to reduce the chance // that we send some mail, hit an exception, then send the mail again when // retrying. foreach ($messages as $mail) { $mail->save(); } $this->queueWebhooks($object, $xactions); return $xactions; } protected function didApplyTransactions($object, array $xactions) { // Hook for subclasses. return $xactions; } private function loadHandles(array $xactions) { $phids = array(); foreach ($xactions as $key => $xaction) { $phids[$key] = $xaction->getRequiredHandlePHIDs(); } $handles = array(); $merged = array_mergev($phids); if ($merged) { $handles = id(new PhabricatorHandleQuery()) ->setViewer($this->requireActor()) ->withPHIDs($merged) ->execute(); } foreach ($xactions as $key => $xaction) { $xaction->setHandles(array_select_keys($handles, $phids[$key])); } } private function loadSubscribers(PhabricatorLiskDAO $object) { if ($object->getPHID() && ($object instanceof PhabricatorSubscribableInterface)) { $subs = PhabricatorSubscribersQuery::loadSubscribersForPHID( $object->getPHID()); $this->subscribers = array_fuse($subs); } else { $this->subscribers = array(); } } private function validateEditParameters( PhabricatorLiskDAO $object, array $xactions) { if (!$this->getContentSource()) { throw new PhutilInvalidStateException('setContentSource'); } // Do a bunch of sanity checks that the incoming transactions are fresh. // They should be unsaved and have only "transactionType" and "newValue" // set. $types = array_fill_keys($this->getTransactionTypes(), true); assert_instances_of($xactions, 'PhabricatorApplicationTransaction'); foreach ($xactions as $xaction) { if ($xaction->getPHID() || $xaction->getID()) { throw new PhabricatorApplicationTransactionStructureException( $xaction, pht('You can not apply transactions which already have IDs/PHIDs!')); } if ($xaction->getObjectPHID()) { throw new PhabricatorApplicationTransactionStructureException( $xaction, pht( 'You can not apply transactions which already have %s!', 'objectPHIDs')); } if ($xaction->getCommentPHID()) { throw new PhabricatorApplicationTransactionStructureException( $xaction, pht( 'You can not apply transactions which already have %s!', 'commentPHIDs')); } if ($xaction->getCommentVersion() !== 0) { throw new PhabricatorApplicationTransactionStructureException( $xaction, pht( 'You can not apply transactions which already have '. 'commentVersions!')); } $expect_value = !$xaction->shouldGenerateOldValue(); $has_value = $xaction->hasOldValue(); // See T13082. In the narrow case of applying inverse edge edits, we // expect the old value to be populated. if ($this->getIsInverseEdgeEditor()) { $expect_value = true; } if ($expect_value && !$has_value) { throw new PhabricatorApplicationTransactionStructureException( $xaction, pht( 'This transaction is supposed to have an %s set, but it does not!', 'oldValue')); } if ($has_value && !$expect_value) { throw new PhabricatorApplicationTransactionStructureException( $xaction, pht( 'This transaction should generate its %s automatically, '. 'but has already had one set!', 'oldValue')); } $type = $xaction->getTransactionType(); if (empty($types[$type])) { throw new PhabricatorApplicationTransactionStructureException( $xaction, pht( 'Transaction has type "%s", but that transaction type is not '. 'supported by this editor (%s).', $type, get_class($this))); } } } private function applyCapabilityChecks( PhabricatorLiskDAO $object, array $xactions) { assert_instances_of($xactions, 'PhabricatorApplicationTransaction'); $can_edit = PhabricatorPolicyCapability::CAN_EDIT; if ($this->getIsNewObject()) { // If we're creating a new object, we don't need any special capabilities // on the object. The actor has already made it through creation checks, // and objects which haven't been created yet often can not be // meaningfully tested for capabilities anyway. $required_capabilities = array(); } else { if (!$xactions && !$this->xactions) { // If we aren't doing anything, require CAN_EDIT to improve consistency. $required_capabilities = array($can_edit); } else { $required_capabilities = array(); foreach ($xactions as $xaction) { $type = $xaction->getTransactionType(); $xtype = $this->getModularTransactionType($type); if (!$xtype) { $capabilities = $this->getLegacyRequiredCapabilities($xaction); } else { $capabilities = $xtype->getRequiredCapabilities($object, $xaction); } // For convenience, we allow flexibility in the return types because // it's very unusual that a transaction actually requires multiple // capability checks. if ($capabilities === null) { $capabilities = array(); } else { $capabilities = (array)$capabilities; } foreach ($capabilities as $capability) { $required_capabilities[$capability] = $capability; } } } } $required_capabilities = array_fuse($required_capabilities); $actor = $this->getActor(); if ($required_capabilities) { id(new PhabricatorPolicyFilter()) ->setViewer($actor) ->requireCapabilities($required_capabilities) ->raisePolicyExceptions(true) ->apply(array($object)); } } private function getLegacyRequiredCapabilities( PhabricatorApplicationTransaction $xaction) { $type = $xaction->getTransactionType(); switch ($type) { case PhabricatorTransactions::TYPE_COMMENT: // TODO: Comments technically require CAN_INTERACT, but this is // currently somewhat special and handled through EditEngine. For now, // don't enforce it here. return null; case PhabricatorTransactions::TYPE_SUBSCRIBERS: // Anyone can subscribe to or unsubscribe from anything they can view, // with no other permissions. $old = array_fuse($xaction->getOldValue()); $new = array_fuse($xaction->getNewValue()); // To remove users other than yourself, you must be able to edit the // object. $rem = array_diff_key($old, $new); foreach ($rem as $phid) { if ($phid !== $this->getActingAsPHID()) { return PhabricatorPolicyCapability::CAN_EDIT; } } // To add users other than yourself, you must be able to interact. // This allows "@mentioning" users to work as long as you can comment // on objects. // If you can edit, we return that policy instead so that you can // override a soft lock and still make edits. // TODO: This is a little bit hacky. We really want to be able to say // "this requires either interact or edit", but there's currently no // way to specify this kind of requirement. $can_edit = PhabricatorPolicyFilter::hasCapability( $this->getActor(), $this->object, PhabricatorPolicyCapability::CAN_EDIT); $add = array_diff_key($new, $old); foreach ($add as $phid) { if ($phid !== $this->getActingAsPHID()) { if ($can_edit) { return PhabricatorPolicyCapability::CAN_EDIT; } else { return PhabricatorPolicyCapability::CAN_INTERACT; } } } return null; case PhabricatorTransactions::TYPE_TOKEN: // TODO: This technically requires CAN_INTERACT, like comments. return null; case PhabricatorTransactions::TYPE_HISTORY: // This is a special magic transaction which sends you history via // email and is only partially supported in the upstream. You don't // need any capabilities to apply it. return null; case PhabricatorTransactions::TYPE_MFA: // Signing a transaction group with MFA does not require permissions // on its own. return null; case PhabricatorTransactions::TYPE_EDGE: return $this->getLegacyRequiredEdgeCapabilities($xaction); default: // For other older (non-modular) transactions, always require exactly // CAN_EDIT. Transactions which do not need CAN_EDIT or need additional // capabilities must move to ModularTransactions. return PhabricatorPolicyCapability::CAN_EDIT; } } private function getLegacyRequiredEdgeCapabilities( PhabricatorApplicationTransaction $xaction) { // You don't need to have edit permission on an object to mention it or // otherwise add a relationship pointing toward it. if ($this->getIsInverseEdgeEditor()) { return null; } $edge_type = $xaction->getMetadataValue('edge:type'); switch ($edge_type) { case PhabricatorMutedByEdgeType::EDGECONST: // At time of writing, you can only write this edge for yourself, so // you don't need permissions. If you can eventually mute an object // for other users, this would need to be revisited. return null; case PhabricatorObjectMentionsObjectEdgeType::EDGECONST: return null; case PhabricatorProjectProjectHasMemberEdgeType::EDGECONST: $old = $xaction->getOldValue(); $new = $xaction->getNewValue(); $add = array_keys(array_diff_key($new, $old)); $rem = array_keys(array_diff_key($old, $new)); $actor_phid = $this->requireActor()->getPHID(); $is_join = (($add === array($actor_phid)) && !$rem); $is_leave = (($rem === array($actor_phid)) && !$add); if ($is_join) { // You need CAN_JOIN to join a project. return PhabricatorPolicyCapability::CAN_JOIN; } if ($is_leave) { $object = $this->object; // You usually don't need any capabilities to leave a project... if ($object->getIsMembershipLocked()) { // ...you must be able to edit to leave locked projects, though. return PhabricatorPolicyCapability::CAN_EDIT; } else { return null; } } // You need CAN_EDIT to change members other than yourself. return PhabricatorPolicyCapability::CAN_EDIT; case PhabricatorObjectHasWatcherEdgeType::EDGECONST: // See PHI1024. Watching a project does not require CAN_EDIT. return null; default: return PhabricatorPolicyCapability::CAN_EDIT; } } private function buildSubscribeTransaction( PhabricatorLiskDAO $object, array $xactions, array $changes) { if (!($object instanceof PhabricatorSubscribableInterface)) { return null; } if ($this->shouldEnableMentions($object, $xactions)) { // Identify newly mentioned users. We ignore users who were previously // mentioned so that we don't re-subscribe users after an edit of text // which mentions them. $old_texts = mpull($changes, 'getOldValue'); $new_texts = mpull($changes, 'getNewValue'); $old_phids = PhabricatorMarkupEngine::extractPHIDsFromMentions( $this->getActor(), $old_texts); $new_phids = PhabricatorMarkupEngine::extractPHIDsFromMentions( $this->getActor(), $new_texts); $phids = array_diff($new_phids, $old_phids); } else { $phids = array(); } $this->mentionedPHIDs = $phids; if ($object->getPHID()) { // Don't try to subscribe already-subscribed mentions: we want to generate // a dialog about an action having no effect if the user explicitly adds // existing CCs, but not if they merely mention existing subscribers. $phids = array_diff($phids, $this->subscribers); } if ($phids) { $users = id(new PhabricatorPeopleQuery()) ->setViewer($this->getActor()) ->withPHIDs($phids) ->execute(); $users = mpull($users, null, 'getPHID'); foreach ($phids as $key => $phid) { $user = idx($users, $phid); // Don't subscribe invalid users. if (!$user) { unset($phids[$key]); continue; } // Don't subscribe bots that get mentioned. If users truly intend // to subscribe them, they can add them explicitly, but it's generally // not useful to subscribe bots to objects. if ($user->getIsSystemAgent()) { unset($phids[$key]); continue; } // Do not subscribe mentioned users who do not have permission to see // the object. if ($object instanceof PhabricatorPolicyInterface) { $can_view = PhabricatorPolicyFilter::hasCapability( $user, $object, PhabricatorPolicyCapability::CAN_VIEW); if (!$can_view) { unset($phids[$key]); continue; } } // Don't subscribe users who are already automatically subscribed. if ($object->isAutomaticallySubscribed($phid)) { unset($phids[$key]); continue; } } $phids = array_values($phids); } if (!$phids) { return null; } $xaction = $object->getApplicationTransactionTemplate() ->setTransactionType(PhabricatorTransactions::TYPE_SUBSCRIBERS) ->setNewValue(array('+' => $phids)); return $xaction; } protected function mergeTransactions( PhabricatorApplicationTransaction $u, PhabricatorApplicationTransaction $v) { $type = $u->getTransactionType(); $xtype = $this->getModularTransactionType($type); if ($xtype) { $object = $this->object; return $xtype->mergeTransactions($object, $u, $v); } switch ($type) { case PhabricatorTransactions::TYPE_SUBSCRIBERS: return $this->mergePHIDOrEdgeTransactions($u, $v); case PhabricatorTransactions::TYPE_EDGE: $u_type = $u->getMetadataValue('edge:type'); $v_type = $v->getMetadataValue('edge:type'); if ($u_type == $v_type) { return $this->mergePHIDOrEdgeTransactions($u, $v); } return null; } // By default, do not merge the transactions. return null; } /** * Optionally expand transactions which imply other effects. For example, * resigning from a revision in Differential implies removing yourself as * a reviewer. */ protected function expandTransactions( PhabricatorLiskDAO $object, array $xactions) { $results = array(); foreach ($xactions as $xaction) { foreach ($this->expandTransaction($object, $xaction) as $expanded) { $results[] = $expanded; } } return $results; } protected function expandTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { return array($xaction); } public function getExpandedSupportTransactions( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { $xactions = array($xaction); $xactions = $this->expandSupportTransactions( $object, $xactions); if (count($xactions) == 1) { return array(); } foreach ($xactions as $index => $cxaction) { if ($cxaction === $xaction) { unset($xactions[$index]); break; } } return $xactions; } private function expandSupportTransactions( PhabricatorLiskDAO $object, array $xactions) { $this->loadSubscribers($object); $xactions = $this->applyImplicitCC($object, $xactions); $changes = $this->getRemarkupChanges($xactions); $subscribe_xaction = $this->buildSubscribeTransaction( $object, $xactions, $changes); if ($subscribe_xaction) { $xactions[] = $subscribe_xaction; } // TODO: For now, this is just a placeholder. $engine = PhabricatorMarkupEngine::getEngine('extract'); $engine->setConfig('viewer', $this->requireActor()); $block_xactions = $this->expandRemarkupBlockTransactions( $object, $xactions, $changes, $engine); foreach ($block_xactions as $xaction) { $xactions[] = $xaction; } return $xactions; } private function getRemarkupChanges(array $xactions) { $changes = array(); foreach ($xactions as $key => $xaction) { foreach ($this->getRemarkupChangesFromTransaction($xaction) as $change) { $changes[] = $change; } } return $changes; } private function getRemarkupChangesFromTransaction( PhabricatorApplicationTransaction $transaction) { return $transaction->getRemarkupChanges(); } private function expandRemarkupBlockTransactions( PhabricatorLiskDAO $object, array $xactions, array $changes, PhutilMarkupEngine $engine) { $block_xactions = $this->expandCustomRemarkupBlockTransactions( $object, $xactions, $changes, $engine); $mentioned_phids = array(); if ($this->shouldEnableMentions($object, $xactions)) { foreach ($changes as $change) { // Here, we don't care about processing only new mentions after an edit // because there is no way for an object to ever "unmention" itself on // another object, so we can ignore the old value. $engine->markupText($change->getNewValue()); $mentioned_phids += $engine->getTextMetadata( PhabricatorObjectRemarkupRule::KEY_MENTIONED_OBJECTS, array()); } } if (!$mentioned_phids) { return $block_xactions; } $mentioned_objects = id(new PhabricatorObjectQuery()) ->setViewer($this->getActor()) ->withPHIDs($mentioned_phids) ->execute(); $unmentionable_map = $this->getUnmentionablePHIDMap(); $mentionable_phids = array(); if ($this->shouldEnableMentions($object, $xactions)) { foreach ($mentioned_objects as $mentioned_object) { if ($mentioned_object instanceof PhabricatorMentionableInterface) { $mentioned_phid = $mentioned_object->getPHID(); if (isset($unmentionable_map[$mentioned_phid])) { continue; } // don't let objects mention themselves if ($object->getPHID() && $mentioned_phid == $object->getPHID()) { continue; } $mentionable_phids[$mentioned_phid] = $mentioned_phid; } } } if ($mentionable_phids) { $edge_type = PhabricatorObjectMentionsObjectEdgeType::EDGECONST; $block_xactions[] = newv(get_class(head($xactions)), array()) ->setIgnoreOnNoEffect(true) ->setTransactionType(PhabricatorTransactions::TYPE_EDGE) ->setMetadataValue('edge:type', $edge_type) ->setNewValue(array('+' => $mentionable_phids)); } return $block_xactions; } protected function expandCustomRemarkupBlockTransactions( PhabricatorLiskDAO $object, array $xactions, array $changes, PhutilMarkupEngine $engine) { return array(); } /** * Attempt to combine similar transactions into a smaller number of total * transactions. For example, two transactions which edit the title of an * object can be merged into a single edit. */ private function combineTransactions(array $xactions) { $stray_comments = array(); $result = array(); $types = array(); foreach ($xactions as $key => $xaction) { $type = $xaction->getTransactionType(); if (isset($types[$type])) { foreach ($types[$type] as $other_key) { $other_xaction = $result[$other_key]; // Don't merge transactions with different authors. For example, // don't merge Herald transactions and owners transactions. if ($other_xaction->getAuthorPHID() != $xaction->getAuthorPHID()) { continue; } $merged = $this->mergeTransactions($result[$other_key], $xaction); if ($merged) { $result[$other_key] = $merged; if ($xaction->getComment() && ($xaction->getComment() !== $merged->getComment())) { $stray_comments[] = $xaction->getComment(); } if ($result[$other_key]->getComment() && ($result[$other_key]->getComment() !== $merged->getComment())) { $stray_comments[] = $result[$other_key]->getComment(); } // Move on to the next transaction. continue 2; } } } $result[$key] = $xaction; $types[$type][] = $key; } // If we merged any comments away, restore them. foreach ($stray_comments as $comment) { $xaction = newv(get_class(head($result)), array()); $xaction->setTransactionType(PhabricatorTransactions::TYPE_COMMENT); $xaction->setComment($comment); $result[] = $xaction; } return array_values($result); } public function mergePHIDOrEdgeTransactions( PhabricatorApplicationTransaction $u, PhabricatorApplicationTransaction $v) { $result = $u->getNewValue(); foreach ($v->getNewValue() as $key => $value) { if ($u->getTransactionType() == PhabricatorTransactions::TYPE_EDGE) { if (empty($result[$key])) { $result[$key] = $value; } else { // We're merging two lists of edge adds, sets, or removes. Merge // them by merging individual PHIDs within them. $merged = $result[$key]; foreach ($value as $dst => $v_spec) { if (empty($merged[$dst])) { $merged[$dst] = $v_spec; } else { // Two transactions are trying to perform the same operation on // the same edge. Normalize the edge data and then merge it. This // allows transactions to specify how data merges execute in a // precise way. $u_spec = $merged[$dst]; if (!is_array($u_spec)) { $u_spec = array('dst' => $u_spec); } if (!is_array($v_spec)) { $v_spec = array('dst' => $v_spec); } $ux_data = idx($u_spec, 'data', array()); $vx_data = idx($v_spec, 'data', array()); $merged_data = $this->mergeEdgeData( $u->getMetadataValue('edge:type'), $ux_data, $vx_data); $u_spec['data'] = $merged_data; $merged[$dst] = $u_spec; } } $result[$key] = $merged; } } else { $result[$key] = array_merge($value, idx($result, $key, array())); } } $u->setNewValue($result); // When combining an "ignore" transaction with a normal transaction, make // sure we don't propagate the "ignore" flag. if (!$v->getIgnoreOnNoEffect()) { $u->setIgnoreOnNoEffect(false); } return $u; } protected function mergeEdgeData($type, array $u, array $v) { return $v + $u; } protected function getPHIDTransactionNewValue( PhabricatorApplicationTransaction $xaction, $old = null) { if ($old !== null) { $old = array_fuse($old); } else { $old = array_fuse($xaction->getOldValue()); } return $this->getPHIDList($old, $xaction->getNewValue()); } public function getPHIDList(array $old, array $new) { $new_add = idx($new, '+', array()); unset($new['+']); $new_rem = idx($new, '-', array()); unset($new['-']); $new_set = idx($new, '=', null); if ($new_set !== null) { $new_set = array_fuse($new_set); } unset($new['=']); if ($new) { throw new Exception( pht( "Invalid '%s' value for PHID transaction. Value should contain only ". "keys '%s' (add PHIDs), '%s' (remove PHIDs) and '%s' (set PHIDS).", 'new', '+', '-', '=')); } $result = array(); foreach ($old as $phid) { if ($new_set !== null && empty($new_set[$phid])) { continue; } $result[$phid] = $phid; } if ($new_set !== null) { foreach ($new_set as $phid) { $result[$phid] = $phid; } } foreach ($new_add as $phid) { $result[$phid] = $phid; } foreach ($new_rem as $phid) { unset($result[$phid]); } return array_values($result); } protected function getEdgeTransactionNewValue( PhabricatorApplicationTransaction $xaction) { $new = $xaction->getNewValue(); $new_add = idx($new, '+', array()); unset($new['+']); $new_rem = idx($new, '-', array()); unset($new['-']); $new_set = idx($new, '=', null); unset($new['=']); if ($new) { throw new Exception( pht( "Invalid '%s' value for Edge transaction. Value should contain only ". "keys '%s' (add edges), '%s' (remove edges) and '%s' (set edges).", 'new', '+', '-', '=')); } $old = $xaction->getOldValue(); $lists = array($new_set, $new_add, $new_rem); foreach ($lists as $list) { $this->checkEdgeList($list, $xaction->getMetadataValue('edge:type')); } $result = array(); foreach ($old as $dst_phid => $edge) { if ($new_set !== null && empty($new_set[$dst_phid])) { continue; } $result[$dst_phid] = $this->normalizeEdgeTransactionValue( $xaction, $edge, $dst_phid); } if ($new_set !== null) { foreach ($new_set as $dst_phid => $edge) { $result[$dst_phid] = $this->normalizeEdgeTransactionValue( $xaction, $edge, $dst_phid); } } foreach ($new_add as $dst_phid => $edge) { $result[$dst_phid] = $this->normalizeEdgeTransactionValue( $xaction, $edge, $dst_phid); } foreach ($new_rem as $dst_phid => $edge) { unset($result[$dst_phid]); } return $result; } private function checkEdgeList($list, $edge_type) { if (!$list) { return; } foreach ($list as $key => $item) { if (phid_get_type($key) === PhabricatorPHIDConstants::PHID_TYPE_UNKNOWN) { throw new Exception( pht( 'Edge transactions must have destination PHIDs as in edge '. 'lists (found key "%s" on transaction of type "%s").', $key, $edge_type)); } if (!is_array($item) && $item !== $key) { throw new Exception( pht( 'Edge transactions must have PHIDs or edge specs as values '. '(found value "%s" on transaction of type "%s").', $item, $edge_type)); } } } private function normalizeEdgeTransactionValue( PhabricatorApplicationTransaction $xaction, $edge, $dst_phid) { if (!is_array($edge)) { if ($edge != $dst_phid) { throw new Exception( pht( 'Transaction edge data must either be the edge PHID or an edge '. 'specification dictionary.')); } $edge = array(); } else { foreach ($edge as $key => $value) { switch ($key) { case 'src': case 'dst': case 'type': case 'data': case 'dateCreated': case 'dateModified': case 'seq': case 'dataID': break; default: throw new Exception( pht( 'Transaction edge specification contains unexpected key "%s".', $key)); } } } $edge['dst'] = $dst_phid; $edge_type = $xaction->getMetadataValue('edge:type'); if (empty($edge['type'])) { $edge['type'] = $edge_type; } else { if ($edge['type'] != $edge_type) { $this_type = $edge['type']; throw new Exception( pht( "Edge transaction includes edge of type '%s', but ". "transaction is of type '%s'. Each edge transaction ". "must alter edges of only one type.", $this_type, $edge_type)); } } if (!isset($edge['data'])) { $edge['data'] = array(); } return $edge; } protected function sortTransactions(array $xactions) { $head = array(); $tail = array(); // Move bare comments to the end, so the actions precede them. foreach ($xactions as $xaction) { $type = $xaction->getTransactionType(); if ($type == PhabricatorTransactions::TYPE_COMMENT) { $tail[] = $xaction; } else { $head[] = $xaction; } } return array_values(array_merge($head, $tail)); } protected function filterTransactions( PhabricatorLiskDAO $object, array $xactions) { $type_comment = PhabricatorTransactions::TYPE_COMMENT; $type_mfa = PhabricatorTransactions::TYPE_MFA; $no_effect = array(); $has_comment = false; $any_effect = false; $meta_xactions = array(); foreach ($xactions as $key => $xaction) { if ($xaction->getTransactionType() === $type_mfa) { $meta_xactions[$key] = $xaction; continue; } if ($this->transactionHasEffect($object, $xaction)) { if ($xaction->getTransactionType() != $type_comment) { $any_effect = true; } } else if ($xaction->getIgnoreOnNoEffect()) { unset($xactions[$key]); } else { $no_effect[$key] = $xaction; } if ($xaction->hasComment()) { $has_comment = true; } } // If every transaction is a meta-transaction applying to the transaction // group, these transactions are junk. if (count($meta_xactions) == count($xactions)) { $no_effect = $xactions; $any_effect = false; } if (!$no_effect) { return $xactions; } // If none of the transactions have an effect, the meta-transactions also // have no effect. Add them to the "no effect" list so we get a full set // of errors for everything. if (!$any_effect && !$has_comment) { $no_effect += $meta_xactions; } if (!$this->getContinueOnNoEffect() && !$this->getIsPreview()) { throw new PhabricatorApplicationTransactionNoEffectException( $no_effect, $any_effect, $has_comment); } if (!$any_effect && !$has_comment) { // If we only have empty comment transactions, just drop them all. return array(); } foreach ($no_effect as $key => $xaction) { if ($xaction->hasComment()) { $xaction->setTransactionType($type_comment); $xaction->setOldValue(null); $xaction->setNewValue(null); } else { unset($xactions[$key]); } } return $xactions; } /** * Hook for validating transactions. This callback will be invoked for each * available transaction type, even if an edit does not apply any transactions * of that type. This allows you to raise exceptions when required fields are * missing, by detecting that the object has no field value and there is no * transaction which sets one. * * @param PhabricatorLiskDAO Object being edited. * @param string Transaction type to validate. * @param list Transactions of given type, * which may be empty if the edit does not apply any transactions of the * given type. * @return list List of * validation errors. */ protected function validateTransaction( PhabricatorLiskDAO $object, $type, array $xactions) { $errors = array(); $xtype = $this->getModularTransactionType($type); if ($xtype) { $errors[] = $xtype->validateTransactions($object, $xactions); } switch ($type) { case PhabricatorTransactions::TYPE_VIEW_POLICY: $errors[] = $this->validatePolicyTransaction( $object, $xactions, $type, PhabricatorPolicyCapability::CAN_VIEW); break; case PhabricatorTransactions::TYPE_EDIT_POLICY: $errors[] = $this->validatePolicyTransaction( $object, $xactions, $type, PhabricatorPolicyCapability::CAN_EDIT); break; case PhabricatorTransactions::TYPE_SPACE: $errors[] = $this->validateSpaceTransactions( $object, $xactions, $type); break; case PhabricatorTransactions::TYPE_SUBTYPE: $errors[] = $this->validateSubtypeTransactions( $object, $xactions, $type); break; case PhabricatorTransactions::TYPE_MFA: $errors[] = $this->validateMFATransactions( $object, $xactions, $type); break; case PhabricatorTransactions::TYPE_CUSTOMFIELD: $groups = array(); foreach ($xactions as $xaction) { $groups[$xaction->getMetadataValue('customfield:key')][] = $xaction; } $field_list = PhabricatorCustomField::getObjectFields( $object, PhabricatorCustomField::ROLE_EDIT); $field_list->setViewer($this->getActor()); $role_xactions = PhabricatorCustomField::ROLE_APPLICATIONTRANSACTIONS; foreach ($field_list->getFields() as $field) { if (!$field->shouldEnableForRole($role_xactions)) { continue; } $errors[] = $field->validateApplicationTransactions( $this, $type, idx($groups, $field->getFieldKey(), array())); } break; } return array_mergev($errors); } public function validatePolicyTransaction( PhabricatorLiskDAO $object, array $xactions, $transaction_type, $capability) { $actor = $this->requireActor(); $errors = array(); // Note $this->xactions is necessary; $xactions is $this->xactions of // $transaction_type $policy_object = $this->adjustObjectForPolicyChecks( $object, $this->xactions); // Make sure the user isn't editing away their ability to $capability this // object. foreach ($xactions as $xaction) { try { PhabricatorPolicyFilter::requireCapabilityWithForcedPolicy( $actor, $policy_object, $capability, $xaction->getNewValue()); } catch (PhabricatorPolicyException $ex) { $errors[] = new PhabricatorApplicationTransactionValidationError( $transaction_type, pht('Invalid'), pht( 'You can not select this %s policy, because you would no longer '. 'be able to %s the object.', $capability, $capability), $xaction); } } if ($this->getIsNewObject()) { if (!$xactions) { $has_capability = PhabricatorPolicyFilter::hasCapability( $actor, $policy_object, $capability); if (!$has_capability) { $errors[] = new PhabricatorApplicationTransactionValidationError( $transaction_type, pht('Invalid'), pht( 'The selected %s policy excludes you. Choose a %s policy '. 'which allows you to %s the object.', $capability, $capability, $capability)); } } } return $errors; } private function validateSpaceTransactions( PhabricatorLiskDAO $object, array $xactions, $transaction_type) { $errors = array(); $actor = $this->getActor(); $has_spaces = PhabricatorSpacesNamespaceQuery::getViewerSpacesExist($actor); $actor_spaces = PhabricatorSpacesNamespaceQuery::getViewerSpaces($actor); $active_spaces = PhabricatorSpacesNamespaceQuery::getViewerActiveSpaces( $actor); foreach ($xactions as $xaction) { $space_phid = $xaction->getNewValue(); if ($space_phid === null) { if (!$has_spaces) { // The install doesn't have any spaces, so this is fine. continue; } // The install has some spaces, so every object needs to be put // in a valid space. $errors[] = new PhabricatorApplicationTransactionValidationError( $transaction_type, pht('Invalid'), pht('You must choose a space for this object.'), $xaction); continue; } // If the PHID isn't `null`, it needs to be a valid space that the // viewer can see. if (empty($actor_spaces[$space_phid])) { $errors[] = new PhabricatorApplicationTransactionValidationError( $transaction_type, pht('Invalid'), pht( 'You can not shift this object in the selected space, because '. 'the space does not exist or you do not have access to it.'), $xaction); } else if (empty($active_spaces[$space_phid])) { // It's OK to edit objects in an archived space, so just move on if // we aren't adjusting the value. $old_space_phid = $this->getTransactionOldValue($object, $xaction); if ($space_phid == $old_space_phid) { continue; } $errors[] = new PhabricatorApplicationTransactionValidationError( $transaction_type, pht('Archived'), pht( 'You can not shift this object into the selected space, because '. 'the space is archived. Objects can not be created inside (or '. 'moved into) archived spaces.'), $xaction); } } return $errors; } private function validateSubtypeTransactions( PhabricatorLiskDAO $object, array $xactions, $transaction_type) { $errors = array(); $map = $object->newEditEngineSubtypeMap(); $old = $object->getEditEngineSubtype(); foreach ($xactions as $xaction) { $new = $xaction->getNewValue(); if ($old == $new) { continue; } if (!$map->isValidSubtype($new)) { $errors[] = new PhabricatorApplicationTransactionValidationError( $transaction_type, pht('Invalid'), pht( 'The subtype "%s" is not a valid subtype.', $new), $xaction); continue; } } return $errors; } private function validateMFATransactions( PhabricatorLiskDAO $object, array $xactions, $transaction_type) { $errors = array(); $factors = id(new PhabricatorAuthFactorConfigQuery()) ->setViewer($this->getActor()) ->withUserPHIDs(array($this->getActingAsPHID())) ->withFactorProviderStatuses( array( PhabricatorAuthFactorProviderStatus::STATUS_ACTIVE, PhabricatorAuthFactorProviderStatus::STATUS_DEPRECATED, )) ->execute(); foreach ($xactions as $xaction) { if (!$factors) { $errors[] = new PhabricatorApplicationTransactionValidationError( $transaction_type, pht('No MFA'), pht( 'You do not have any MFA factors attached to your account, so '. 'you can not sign this transaction group with MFA. Add MFA to '. 'your account in Settings.'), $xaction); } } if ($xactions) { $this->setShouldRequireMFA(true); } return $errors; } protected function adjustObjectForPolicyChecks( PhabricatorLiskDAO $object, array $xactions) { $copy = clone $object; foreach ($xactions as $xaction) { switch ($xaction->getTransactionType()) { case PhabricatorTransactions::TYPE_SUBSCRIBERS: $clone_xaction = clone $xaction; $clone_xaction->setOldValue(array_values($this->subscribers)); $clone_xaction->setNewValue( $this->getPHIDTransactionNewValue( $clone_xaction)); PhabricatorPolicyRule::passTransactionHintToRule( $copy, new PhabricatorSubscriptionsSubscribersPolicyRule(), array_fuse($clone_xaction->getNewValue())); break; case PhabricatorTransactions::TYPE_SPACE: $space_phid = $this->getTransactionNewValue($object, $xaction); $copy->setSpacePHID($space_phid); break; } } return $copy; } protected function validateAllTransactions( PhabricatorLiskDAO $object, array $xactions) { return array(); } /** * Check for a missing text field. * * A text field is missing if the object has no value and there are no * transactions which set a value, or if the transactions remove the value. * This method is intended to make implementing @{method:validateTransaction} * more convenient: * * $missing = $this->validateIsEmptyTextField( * $object->getName(), * $xactions); * * This will return `true` if the net effect of the object and transactions * is an empty field. * * @param wild Current field value. * @param list Transactions editing the * field. * @return bool True if the field will be an empty text field after edits. */ protected function validateIsEmptyTextField($field_value, array $xactions) { if (strlen($field_value) && empty($xactions)) { return false; } if ($xactions && strlen(last($xactions)->getNewValue())) { return false; } return true; } /* -( Implicit CCs )------------------------------------------------------- */ /** * When a user interacts with an object, we might want to add them to CC. */ final public function applyImplicitCC( PhabricatorLiskDAO $object, array $xactions) { if (!($object instanceof PhabricatorSubscribableInterface)) { // If the object isn't subscribable, we can't CC them. return $xactions; } $actor_phid = $this->getActingAsPHID(); $type_user = PhabricatorPeopleUserPHIDType::TYPECONST; if (phid_get_type($actor_phid) != $type_user) { // Transactions by application actors like Herald, Harbormaster and // Diffusion should not CC the applications. return $xactions; } if ($object->isAutomaticallySubscribed($actor_phid)) { // If they're auto-subscribed, don't CC them. return $xactions; } $should_cc = false; foreach ($xactions as $xaction) { if ($this->shouldImplyCC($object, $xaction)) { $should_cc = true; break; } } if (!$should_cc) { // Only some types of actions imply a CC (like adding a comment). return $xactions; } if ($object->getPHID()) { if (isset($this->subscribers[$actor_phid])) { // If the user is already subscribed, don't implicitly CC them. return $xactions; } $unsub = PhabricatorEdgeQuery::loadDestinationPHIDs( $object->getPHID(), PhabricatorObjectHasUnsubscriberEdgeType::EDGECONST); $unsub = array_fuse($unsub); if (isset($unsub[$actor_phid])) { // If the user has previously unsubscribed from this object explicitly, // don't implicitly CC them. return $xactions; } } $actor = $this->getActor(); $user = id(new PhabricatorPeopleQuery()) ->setViewer($actor) ->withPHIDs(array($actor_phid)) ->executeOne(); if (!$user) { return $xactions; } // When a bot acts (usually via the API), don't automatically subscribe // them as a side effect. They can always subscribe explicitly if they // want, and bot subscriptions normally just clutter things up since bots // usually do not read email. if ($user->getIsSystemAgent()) { return $xactions; } $xaction = newv(get_class(head($xactions)), array()); $xaction->setTransactionType(PhabricatorTransactions::TYPE_SUBSCRIBERS); $xaction->setNewValue(array('+' => array($actor_phid))); array_unshift($xactions, $xaction); return $xactions; } protected function shouldImplyCC( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { return $xaction->isCommentTransaction(); } /* -( Sending Mail )------------------------------------------------------- */ /** * @task mail */ protected function shouldSendMail( PhabricatorLiskDAO $object, array $xactions) { return false; } /** * @task mail */ private function buildMail( PhabricatorLiskDAO $object, array $xactions) { $email_to = $this->mailToPHIDs; $email_cc = $this->mailCCPHIDs; $email_cc = array_merge($email_cc, $this->heraldEmailPHIDs); $unexpandable = $this->mailUnexpandablePHIDs; if (!is_array($unexpandable)) { $unexpandable = array(); } $messages = $this->buildMailWithRecipients( $object, $xactions, $email_to, $email_cc, $unexpandable); $this->runHeraldMailRules($messages); return $messages; } private function buildMailWithRecipients( PhabricatorLiskDAO $object, array $xactions, array $email_to, array $email_cc, array $unexpandable) { $targets = $this->buildReplyHandler($object) ->setUnexpandablePHIDs($unexpandable) ->getMailTargets($email_to, $email_cc); // Set this explicitly before we start swapping out the effective actor. $this->setActingAsPHID($this->getActingAsPHID()); $xaction_phids = mpull($xactions, 'getPHID'); $messages = array(); foreach ($targets as $target) { $original_actor = $this->getActor(); $viewer = $target->getViewer(); $this->setActor($viewer); $locale = PhabricatorEnv::beginScopedLocale($viewer->getTranslation()); $caught = null; $mail = null; try { // Reload the transactions for the current viewer. if ($xaction_phids) { $query = PhabricatorApplicationTransactionQuery::newQueryForObject( $object); $mail_xactions = $query ->setViewer($viewer) ->withObjectPHIDs(array($object->getPHID())) ->withPHIDs($xaction_phids) ->execute(); // Sort the mail transactions in the input order. $mail_xactions = mpull($mail_xactions, null, 'getPHID'); $mail_xactions = array_select_keys($mail_xactions, $xaction_phids); $mail_xactions = array_values($mail_xactions); } else { $mail_xactions = array(); } // Reload handles for the current viewer. This covers older code which // emits a list of handle PHIDs upfront. $this->loadHandles($mail_xactions); $mail = $this->buildMailForTarget($object, $mail_xactions, $target); if ($mail) { if ($this->mustEncrypt) { $mail ->setMustEncrypt(true) ->setMustEncryptReasons($this->mustEncrypt); } } } catch (Exception $ex) { $caught = $ex; } $this->setActor($original_actor); unset($locale); if ($caught) { throw $ex; } if ($mail) { $messages[] = $mail; } } return $messages; } protected function getTransactionsForMail( PhabricatorLiskDAO $object, array $xactions) { return $xactions; } private function buildMailForTarget( PhabricatorLiskDAO $object, array $xactions, PhabricatorMailTarget $target) { // Check if any of the transactions are visible for this viewer. If we // don't have any visible transactions, don't send the mail. $any_visible = false; foreach ($xactions as $xaction) { if (!$xaction->shouldHideForMail($xactions)) { $any_visible = true; break; } } if (!$any_visible) { return null; } $mail_xactions = $this->getTransactionsForMail($object, $xactions); $mail = $this->buildMailTemplate($object); $body = $this->buildMailBody($object, $mail_xactions); $mail_tags = $this->getMailTags($object, $mail_xactions); $action = $this->getMailAction($object, $mail_xactions); $stamps = $this->generateMailStamps($object, $this->mailStamps); if (PhabricatorEnv::getEnvConfig('metamta.email-preferences')) { $this->addEmailPreferenceSectionToMailBody( $body, $object, $mail_xactions); } $muted_phids = $this->mailMutedPHIDs; if (!is_array($muted_phids)) { $muted_phids = array(); } $mail ->setSensitiveContent(false) ->setFrom($this->getActingAsPHID()) ->setSubjectPrefix($this->getMailSubjectPrefix()) ->setVarySubjectPrefix('['.$action.']') ->setThreadID($this->getMailThreadID($object), $this->getIsNewObject()) ->setRelatedPHID($object->getPHID()) ->setExcludeMailRecipientPHIDs($this->getExcludeMailRecipientPHIDs()) ->setMutedPHIDs($muted_phids) ->setForceHeraldMailRecipientPHIDs($this->heraldForcedEmailPHIDs) ->setMailTags($mail_tags) ->setIsBulk(true) ->setBody($body->render()) ->setHTMLBody($body->renderHTML()); foreach ($body->getAttachments() as $attachment) { $mail->addAttachment($attachment); } if ($this->heraldHeader) { $mail->addHeader('X-Herald-Rules', $this->heraldHeader); } if ($object instanceof PhabricatorProjectInterface) { $this->addMailProjectMetadata($object, $mail); } if ($this->getParentMessageID()) { $mail->setParentMessageID($this->getParentMessageID()); } // If we have stamps, attach the raw dictionary version (not the actual // objects) to the mail so that debugging tools can see what we used to // render the final list. if ($this->mailStamps) { $mail->setMailStampMetadata($this->mailStamps); } // If we have rendered stamps, attach them to the mail. if ($stamps) { $mail->setMailStamps($stamps); } return $target->willSendMail($mail); } private function addMailProjectMetadata( PhabricatorLiskDAO $object, PhabricatorMetaMTAMail $template) { $project_phids = PhabricatorEdgeQuery::loadDestinationPHIDs( $object->getPHID(), PhabricatorProjectObjectHasProjectEdgeType::EDGECONST); if (!$project_phids) { return; } // TODO: This viewer isn't quite right. It would be slightly better to use // the mail recipient, but that's not very easy given the way rendering // works today. $handles = id(new PhabricatorHandleQuery()) ->setViewer($this->requireActor()) ->withPHIDs($project_phids) ->execute(); $project_tags = array(); foreach ($handles as $handle) { if (!$handle->isComplete()) { continue; } $project_tags[] = '<'.$handle->getObjectName().'>'; } if (!$project_tags) { return; } $project_tags = implode(', ', $project_tags); $template->addHeader('X-Phabricator-Projects', $project_tags); } protected function getMailThreadID(PhabricatorLiskDAO $object) { return $object->getPHID(); } /** * @task mail */ protected function getStrongestAction( PhabricatorLiskDAO $object, array $xactions) { return head(msortv($xactions, 'newActionStrengthSortVector')); } /** * @task mail */ protected function buildReplyHandler(PhabricatorLiskDAO $object) { throw new Exception(pht('Capability not supported.')); } /** * @task mail */ protected function getMailSubjectPrefix() { throw new Exception(pht('Capability not supported.')); } /** * @task mail */ protected function getMailTags( PhabricatorLiskDAO $object, array $xactions) { $tags = array(); foreach ($xactions as $xaction) { $tags[] = $xaction->getMailTags(); } return array_mergev($tags); } /** * @task mail */ public function getMailTagsMap() { // TODO: We should move shared mail tags, like "comment", here. return array(); } /** * @task mail */ protected function getMailAction( PhabricatorLiskDAO $object, array $xactions) { return $this->getStrongestAction($object, $xactions)->getActionName(); } /** * @task mail */ protected function buildMailTemplate(PhabricatorLiskDAO $object) { throw new Exception(pht('Capability not supported.')); } /** * @task mail */ protected function getMailTo(PhabricatorLiskDAO $object) { throw new Exception(pht('Capability not supported.')); } protected function newMailUnexpandablePHIDs(PhabricatorLiskDAO $object) { return array(); } /** * @task mail */ protected function getMailCC(PhabricatorLiskDAO $object) { $phids = array(); $has_support = false; if ($object instanceof PhabricatorSubscribableInterface) { $phid = $object->getPHID(); $phids[] = PhabricatorSubscribersQuery::loadSubscribersForPHID($phid); $has_support = true; } if ($object instanceof PhabricatorProjectInterface) { $project_phids = PhabricatorEdgeQuery::loadDestinationPHIDs( $object->getPHID(), PhabricatorProjectObjectHasProjectEdgeType::EDGECONST); if ($project_phids) { $projects = id(new PhabricatorProjectQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withPHIDs($project_phids) ->needWatchers(true) ->execute(); $watcher_phids = array(); foreach ($projects as $project) { foreach ($project->getAllAncestorWatcherPHIDs() as $phid) { $watcher_phids[$phid] = $phid; } } if ($watcher_phids) { // We need to do a visibility check for all the watchers, as // watching a project is not a guarantee that you can see objects // associated with it. $users = id(new PhabricatorPeopleQuery()) ->setViewer($this->requireActor()) ->withPHIDs($watcher_phids) ->execute(); $watchers = array(); foreach ($users as $user) { $can_see = PhabricatorPolicyFilter::hasCapability( $user, $object, PhabricatorPolicyCapability::CAN_VIEW); if ($can_see) { $watchers[] = $user->getPHID(); } } $phids[] = $watchers; } } $has_support = true; } if (!$has_support) { throw new Exception( pht('The object being edited does not implement any standard '. 'interfaces (like PhabricatorSubscribableInterface) which allow '. 'CCs to be generated automatically. Override the "getMailCC()" '. 'method and generate CCs explicitly.')); } return array_mergev($phids); } /** * @task mail */ protected function buildMailBody( PhabricatorLiskDAO $object, array $xactions) { $body = id(new PhabricatorMetaMTAMailBody()) ->setViewer($this->requireActor()) ->setContextObject($object); $button_label = $this->getObjectLinkButtonLabelForMail($object); + $button_uri = $this->getObjectLinkButtonURIForMail($object); + + $this->addHeadersAndCommentsToMailBody( + $body, + $xactions, + $button_label, + $button_uri); - $this->addHeadersAndCommentsToMailBody($body, $xactions, $button_label); $this->addCustomFieldsToMailBody($body, $object, $xactions); return $body; } - protected function getObjectLinkButtonLabelForMail() { + protected function getObjectLinkButtonLabelForMail( + PhabricatorLiskDAO $object) { return null; } + protected function getObjectLinkButtonURIForMail( + PhabricatorLiskDAO $object) { + + // Most objects define a "getURI()" method which does what we want, but + // this isn't formally part of an interface at time of writing. Try to + // call the method, expecting an exception if it does not exist. + + try { + $uri = $object->getURI(); + return PhabricatorEnv::getProductionURI($uri); + } catch (Exception $ex) { + return null; + } + } + /** * @task mail */ protected function addEmailPreferenceSectionToMailBody( PhabricatorMetaMTAMailBody $body, PhabricatorLiskDAO $object, array $xactions) { $href = PhabricatorEnv::getProductionURI( '/settings/panel/emailpreferences/'); $body->addLinkSection(pht('EMAIL PREFERENCES'), $href); } /** * @task mail */ protected function addHeadersAndCommentsToMailBody( PhabricatorMetaMTAMailBody $body, array $xactions, $object_label = null, - $object_href = null) { + $object_uri = null) { // First, remove transactions which shouldn't be rendered in mail. foreach ($xactions as $key => $xaction) { if ($xaction->shouldHideForMail($xactions)) { unset($xactions[$key]); } } $headers = array(); $headers_html = array(); $comments = array(); $details = array(); $seen_comment = false; foreach ($xactions as $xaction) { // Most mail has zero or one comments. In these cases, we render the // "alice added a comment." transaction in the header, like a normal // transaction. // Some mail, like Differential undraft mail or "!history" mail, may // have two or more comments. In these cases, we'll put the first // "alice added a comment." transaction in the header normally, but // move the other transactions down so they provide context above the // actual comment. $comment = $this->getBodyForTextMail($xaction); if ($comment !== null) { $is_comment = true; $comments[] = array( 'xaction' => $xaction, 'comment' => $comment, 'initial' => !$seen_comment, ); } else { $is_comment = false; } if (!$is_comment || !$seen_comment) { $header = $this->getTitleForTextMail($xaction); if ($header !== null) { $headers[] = $header; } $header_html = $this->getTitleForHTMLMail($xaction); if ($header_html !== null) { $headers_html[] = $header_html; } } if ($xaction->hasChangeDetailsForMail()) { $details[] = $xaction; } if ($is_comment) { $seen_comment = true; } } $headers_text = implode("\n", $headers); $body->addRawPlaintextSection($headers_text); $headers_html = phutil_implode_html(phutil_tag('br'), $headers_html); $header_button = null; - if ($object_label !== null) { + if ($object_label !== null && $object_uri !== null) { $button_style = array( 'text-decoration: none;', 'padding: 4px 8px;', 'margin: 0 8px 8px;', 'float: right;', 'color: #464C5C;', 'font-weight: bold;', 'border-radius: 3px;', 'background-color: #F7F7F9;', 'background-image: linear-gradient(to bottom,#fff,#f1f0f1);', 'display: inline-block;', 'border: 1px solid rgba(71,87,120,.2);', ); $header_button = phutil_tag( 'a', array( 'style' => implode(' ', $button_style), - 'href' => $object_href, + 'href' => $object_uri, ), $object_label); } $xactions_style = array(); $header_action = phutil_tag( 'td', array(), $header_button); $header_action = phutil_tag( 'td', array( 'style' => implode(' ', $xactions_style), ), array( $headers_html, // Add an extra newline to prevent the "View Object" button from // running into the transaction text in Mail.app text snippet // previews. "\n", )); $headers_html = phutil_tag( 'table', array(), phutil_tag('tr', array(), array($header_action, $header_button))); $body->addRawHTMLSection($headers_html); foreach ($comments as $spec) { $xaction = $spec['xaction']; $comment = $spec['comment']; $is_initial = $spec['initial']; // If this is not the first comment in the mail, add the header showing // who wrote the comment immediately above the comment. if (!$is_initial) { $header = $this->getTitleForTextMail($xaction); if ($header !== null) { $body->addRawPlaintextSection($header); } $header_html = $this->getTitleForHTMLMail($xaction); if ($header_html !== null) { $body->addRawHTMLSection($header_html); } } $body->addRemarkupSection(null, $comment); } foreach ($details as $xaction) { $details = $xaction->renderChangeDetailsForMail($body->getViewer()); if ($details !== null) { $label = $this->getMailDiffSectionHeader($xaction); $body->addHTMLSection($label, $details); } } } private function getMailDiffSectionHeader($xaction) { $type = $xaction->getTransactionType(); $xtype = $this->getModularTransactionType($type); if ($xtype) { return $xtype->getMailDiffSectionHeader(); } return pht('EDIT DETAILS'); } /** * @task mail */ protected function addCustomFieldsToMailBody( PhabricatorMetaMTAMailBody $body, PhabricatorLiskDAO $object, array $xactions) { if ($object instanceof PhabricatorCustomFieldInterface) { $field_list = PhabricatorCustomField::getObjectFields( $object, PhabricatorCustomField::ROLE_TRANSACTIONMAIL); $field_list->setViewer($this->getActor()); $field_list->readFieldsFromStorage($object); foreach ($field_list->getFields() as $field) { $field->updateTransactionMailBody( $body, $this, $xactions); } } } /** * @task mail */ private function runHeraldMailRules(array $messages) { foreach ($messages as $message) { $engine = new HeraldEngine(); $adapter = id(new PhabricatorMailOutboundMailHeraldAdapter()) ->setObject($message); $rules = $engine->loadRulesForAdapter($adapter); $effects = $engine->applyRules($rules, $adapter); $engine->applyEffects($effects, $adapter, $rules); } } /* -( Publishing Feed Stories )-------------------------------------------- */ /** * @task feed */ protected function shouldPublishFeedStory( PhabricatorLiskDAO $object, array $xactions) { return false; } /** * @task feed */ protected function getFeedStoryType() { return 'PhabricatorApplicationTransactionFeedStory'; } /** * @task feed */ protected function getFeedRelatedPHIDs( PhabricatorLiskDAO $object, array $xactions) { $phids = array( $object->getPHID(), $this->getActingAsPHID(), ); if ($object instanceof PhabricatorProjectInterface) { $project_phids = PhabricatorEdgeQuery::loadDestinationPHIDs( $object->getPHID(), PhabricatorProjectObjectHasProjectEdgeType::EDGECONST); foreach ($project_phids as $project_phid) { $phids[] = $project_phid; } } return $phids; } /** * @task feed */ protected function getFeedNotifyPHIDs( PhabricatorLiskDAO $object, array $xactions) { // If some transactions are forcing notification delivery, add the forced // recipients to the notify list. $force_list = array(); foreach ($xactions as $xaction) { $force_phids = $xaction->getForceNotifyPHIDs(); if (!$force_phids) { continue; } foreach ($force_phids as $force_phid) { $force_list[] = $force_phid; } } $to_list = $this->getMailTo($object); $cc_list = $this->getMailCC($object); $full_list = array_merge($force_list, $to_list, $cc_list); $full_list = array_fuse($full_list); return array_keys($full_list); } /** * @task feed */ protected function getFeedStoryData( PhabricatorLiskDAO $object, array $xactions) { $xactions = msortv($xactions, 'newActionStrengthSortVector'); return array( 'objectPHID' => $object->getPHID(), 'transactionPHIDs' => mpull($xactions, 'getPHID'), ); } /** * @task feed */ protected function publishFeedStory( PhabricatorLiskDAO $object, array $xactions, array $mailed_phids) { // Remove transactions which don't publish feed stories or notifications. // These never show up anywhere, so we don't need to do anything with them. foreach ($xactions as $key => $xaction) { if (!$xaction->shouldHideForFeed()) { continue; } if (!$xaction->shouldHideForNotifications()) { continue; } unset($xactions[$key]); } if (!$xactions) { return; } $related_phids = $this->feedRelatedPHIDs; $subscribed_phids = $this->feedNotifyPHIDs; // Remove muted users from the subscription list so they don't get // notifications, either. $muted_phids = $this->mailMutedPHIDs; if (!is_array($muted_phids)) { $muted_phids = array(); } $subscribed_phids = array_fuse($subscribed_phids); foreach ($muted_phids as $muted_phid) { unset($subscribed_phids[$muted_phid]); } $subscribed_phids = array_values($subscribed_phids); $story_type = $this->getFeedStoryType(); $story_data = $this->getFeedStoryData($object, $xactions); $unexpandable_phids = $this->mailUnexpandablePHIDs; if (!is_array($unexpandable_phids)) { $unexpandable_phids = array(); } id(new PhabricatorFeedStoryPublisher()) ->setStoryType($story_type) ->setStoryData($story_data) ->setStoryTime(time()) ->setStoryAuthorPHID($this->getActingAsPHID()) ->setRelatedPHIDs($related_phids) ->setPrimaryObjectPHID($object->getPHID()) ->setSubscribedPHIDs($subscribed_phids) ->setUnexpandablePHIDs($unexpandable_phids) ->setMailRecipientPHIDs($mailed_phids) ->setMailTags($this->getMailTags($object, $xactions)) ->publish(); } /* -( Search Index )------------------------------------------------------- */ /** * @task search */ protected function supportsSearch() { return false; } /* -( Herald Integration )-------------------------------------------------- */ protected function shouldApplyHeraldRules( PhabricatorLiskDAO $object, array $xactions) { return false; } protected function buildHeraldAdapter( PhabricatorLiskDAO $object, array $xactions) { throw new Exception(pht('No herald adapter specified.')); } private function setHeraldAdapter(HeraldAdapter $adapter) { $this->heraldAdapter = $adapter; return $this; } protected function getHeraldAdapter() { return $this->heraldAdapter; } private function setHeraldTranscript(HeraldTranscript $transcript) { $this->heraldTranscript = $transcript; return $this; } protected function getHeraldTranscript() { return $this->heraldTranscript; } private function applyHeraldRules( PhabricatorLiskDAO $object, array $xactions) { $adapter = $this->buildHeraldAdapter($object, $xactions) ->setContentSource($this->getContentSource()) ->setIsNewObject($this->getIsNewObject()) ->setActingAsPHID($this->getActingAsPHID()) ->setAppliedTransactions($xactions); if ($this->getApplicationEmail()) { $adapter->setApplicationEmail($this->getApplicationEmail()); } // If this editor is operating in silent mode, tell Herald that we aren't // going to send any mail. This allows it to skip "the first time this // rule matches, send me an email" rules which would otherwise match even // though we aren't going to send any mail. if ($this->getIsSilent()) { $adapter->setForbiddenAction( HeraldMailableState::STATECONST, HeraldCoreStateReasons::REASON_SILENT); } $xscript = HeraldEngine::loadAndApplyRules($adapter); $this->setHeraldAdapter($adapter); $this->setHeraldTranscript($xscript); if ($adapter instanceof HarbormasterBuildableAdapterInterface) { $buildable_phid = $adapter->getHarbormasterBuildablePHID(); HarbormasterBuildable::applyBuildPlans( $buildable_phid, $adapter->getHarbormasterContainerPHID(), $adapter->getQueuedHarbormasterBuildRequests()); // Whether we queued any builds or not, any automatic buildable for this // object is now done preparing builds and can transition into a // completed status. $buildables = id(new HarbormasterBuildableQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withManualBuildables(false) ->withBuildablePHIDs(array($buildable_phid)) ->execute(); foreach ($buildables as $buildable) { // If this buildable has already moved beyond preparation, we don't // need to nudge it again. if (!$buildable->isPreparing()) { continue; } $buildable->sendMessage( $this->getActor(), HarbormasterMessageType::BUILDABLE_BUILD, true); } } $this->mustEncrypt = $adapter->getMustEncryptReasons(); // See PHI1134. Propagate "Must Encrypt" state to sub-editors. foreach ($this->subEditors as $sub_editor) { $sub_editor->mustEncrypt = $this->mustEncrypt; } $apply_xactions = $this->didApplyHeraldRules($object, $adapter, $xscript); assert_instances_of($apply_xactions, 'PhabricatorApplicationTransaction'); $queue_xactions = $adapter->getQueuedTransactions(); return array_merge( array_values($apply_xactions), array_values($queue_xactions)); } protected function didApplyHeraldRules( PhabricatorLiskDAO $object, HeraldAdapter $adapter, HeraldTranscript $transcript) { return array(); } /* -( Custom Fields )------------------------------------------------------ */ /** * @task customfield */ private function getCustomFieldForTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { $field_key = $xaction->getMetadataValue('customfield:key'); if (!$field_key) { throw new Exception( pht( "Custom field transaction has no '%s'!", 'customfield:key')); } $field = PhabricatorCustomField::getObjectField( $object, PhabricatorCustomField::ROLE_APPLICATIONTRANSACTIONS, $field_key); if (!$field) { throw new Exception( pht( "Custom field transaction has invalid '%s'; field '%s' ". "is disabled or does not exist.", 'customfield:key', $field_key)); } if (!$field->shouldAppearInApplicationTransactions()) { throw new Exception( pht( "Custom field transaction '%s' does not implement ". "integration for %s.", $field_key, 'ApplicationTransactions')); } $field->setViewer($this->getActor()); return $field; } /* -( Files )-------------------------------------------------------------- */ /** * Extract the PHIDs of any files which these transactions attach. * * @task files */ private function extractFilePHIDs( PhabricatorLiskDAO $object, array $xactions) { $changes = $this->getRemarkupChanges($xactions); $blocks = mpull($changes, 'getNewValue'); $phids = array(); if ($blocks) { $phids[] = PhabricatorMarkupEngine::extractFilePHIDsFromEmbeddedFiles( $this->getActor(), $blocks); } foreach ($xactions as $xaction) { $type = $xaction->getTransactionType(); $xtype = $this->getModularTransactionType($type); if ($xtype) { $phids[] = $xtype->extractFilePHIDs($object, $xaction->getNewValue()); } else { $phids[] = $this->extractFilePHIDsFromCustomTransaction( $object, $xaction); } } $phids = array_unique(array_filter(array_mergev($phids))); if (!$phids) { return array(); } // Only let a user attach files they can actually see, since this would // otherwise let you access any file by attaching it to an object you have // view permission on. $files = id(new PhabricatorFileQuery()) ->setViewer($this->getActor()) ->withPHIDs($phids) ->execute(); return mpull($files, 'getPHID'); } /** * @task files */ protected function extractFilePHIDsFromCustomTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { return array(); } /** * @task files */ private function attachFiles( PhabricatorLiskDAO $object, array $file_phids) { if (!$file_phids) { return; } $editor = new PhabricatorEdgeEditor(); $src = $object->getPHID(); $type = PhabricatorObjectHasFileEdgeType::EDGECONST; foreach ($file_phids as $dst) { $editor->addEdge($src, $type, $dst); } $editor->save(); } private function applyInverseEdgeTransactions( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction, $inverse_type) { $old = $xaction->getOldValue(); $new = $xaction->getNewValue(); $add = array_keys(array_diff_key($new, $old)); $rem = array_keys(array_diff_key($old, $new)); $add = array_fuse($add); $rem = array_fuse($rem); $all = $add + $rem; $nodes = id(new PhabricatorObjectQuery()) ->setViewer($this->requireActor()) ->withPHIDs($all) ->execute(); $object_phid = $object->getPHID(); foreach ($nodes as $node) { if (!($node instanceof PhabricatorApplicationTransactionInterface)) { continue; } if ($node instanceof PhabricatorUser) { // TODO: At least for now, don't record inverse edge transactions // for users (for example, "alincoln joined project X"): Feed fills // this role instead. continue; } $node_phid = $node->getPHID(); $editor = $node->getApplicationTransactionEditor(); $template = $node->getApplicationTransactionTemplate(); // See T13082. We have to build these transactions with synthetic values // because we've already applied the actual edit to the edge database // table. If we try to apply this transaction naturally, it will no-op // itself because it doesn't have any effect. $edge_query = id(new PhabricatorEdgeQuery()) ->withSourcePHIDs(array($node_phid)) ->withEdgeTypes(array($inverse_type)); $edge_query->execute(); $edge_phids = $edge_query->getDestinationPHIDs(); $edge_phids = array_fuse($edge_phids); $new_phids = $edge_phids; $old_phids = $edge_phids; if (isset($add[$node_phid])) { unset($old_phids[$object_phid]); } else { $old_phids[$object_phid] = $object_phid; } $template ->setTransactionType($xaction->getTransactionType()) ->setMetadataValue('edge:type', $inverse_type) ->setOldValue($old_phids) ->setNewValue($new_phids); $editor = $this->newSubEditor($editor) ->setContinueOnNoEffect(true) ->setContinueOnMissingFields(true) ->setIsInverseEdgeEditor(true); $editor->applyTransactions($node, array($template)); } } /* -( Workers )------------------------------------------------------------ */ /** * Load any object state which is required to publish transactions. * * This hook is invoked in the main process before we compute data related * to publishing transactions (like email "To" and "CC" lists), and again in * the worker before publishing occurs. * * @return object Publishable object. * @task workers */ protected function willPublish(PhabricatorLiskDAO $object, array $xactions) { return $object; } /** * Convert the editor state to a serializable dictionary which can be passed * to a worker. * * This data will be loaded with @{method:loadWorkerState} in the worker. * * @return dict Serializable editor state. * @task workers */ final private function getWorkerState() { $state = array(); foreach ($this->getAutomaticStateProperties() as $property) { $state[$property] = $this->$property; } $custom_state = $this->getCustomWorkerState(); $custom_encoding = $this->getCustomWorkerStateEncoding(); $state += array( 'excludeMailRecipientPHIDs' => $this->getExcludeMailRecipientPHIDs(), 'custom' => $this->encodeStateForStorage($custom_state, $custom_encoding), 'custom.encoding' => $custom_encoding, ); return $state; } /** * Hook; return custom properties which need to be passed to workers. * * @return dict Custom properties. * @task workers */ protected function getCustomWorkerState() { return array(); } /** * Hook; return storage encoding for custom properties which need to be * passed to workers. * * This primarily allows binary data to be passed to workers and survive * JSON encoding. * * @return dict Property encodings. * @task workers */ protected function getCustomWorkerStateEncoding() { return array(); } /** * Load editor state using a dictionary emitted by @{method:getWorkerState}. * * This method is used to load state when running worker operations. * * @param dict Editor state, from @{method:getWorkerState}. * @return this * @task workers */ final public function loadWorkerState(array $state) { foreach ($this->getAutomaticStateProperties() as $property) { $this->$property = idx($state, $property); } $exclude = idx($state, 'excludeMailRecipientPHIDs', array()); $this->setExcludeMailRecipientPHIDs($exclude); $custom_state = idx($state, 'custom', array()); $custom_encodings = idx($state, 'custom.encoding', array()); $custom = $this->decodeStateFromStorage($custom_state, $custom_encodings); $this->loadCustomWorkerState($custom); return $this; } /** * Hook; set custom properties on the editor from data emitted by * @{method:getCustomWorkerState}. * * @param dict Custom state, * from @{method:getCustomWorkerState}. * @return this * @task workers */ protected function loadCustomWorkerState(array $state) { return $this; } /** * Get a list of object properties which should be automatically sent to * workers in the state data. * * These properties will be automatically stored and loaded by the editor in * the worker. * * @return list List of properties. * @task workers */ private function getAutomaticStateProperties() { return array( 'parentMessageID', 'isNewObject', 'heraldEmailPHIDs', 'heraldForcedEmailPHIDs', 'heraldHeader', 'mailToPHIDs', 'mailCCPHIDs', 'feedNotifyPHIDs', 'feedRelatedPHIDs', 'feedShouldPublish', 'mailShouldSend', 'mustEncrypt', 'mailStamps', 'mailUnexpandablePHIDs', 'mailMutedPHIDs', 'webhookMap', 'silent', 'sendHistory', ); } /** * Apply encodings prior to storage. * * See @{method:getCustomWorkerStateEncoding}. * * @param map Map of values to encode. * @param map Map of encodings to apply. * @return map Map of encoded values. * @task workers */ final private function encodeStateForStorage( array $state, array $encodings) { foreach ($state as $key => $value) { $encoding = idx($encodings, $key); switch ($encoding) { case self::STORAGE_ENCODING_BINARY: // The mechanics of this encoding (serialize + base64) are a little // awkward, but it allows us encode arrays and still be JSON-safe // with binary data. $value = @serialize($value); if ($value === false) { throw new Exception( pht( 'Failed to serialize() value for key "%s".', $key)); } $value = base64_encode($value); if ($value === false) { throw new Exception( pht( 'Failed to base64 encode value for key "%s".', $key)); } break; } $state[$key] = $value; } return $state; } /** * Undo storage encoding applied when storing state. * * See @{method:getCustomWorkerStateEncoding}. * * @param map Map of encoded values. * @param map Map of encodings. * @return map Map of decoded values. * @task workers */ final private function decodeStateFromStorage( array $state, array $encodings) { foreach ($state as $key => $value) { $encoding = idx($encodings, $key); switch ($encoding) { case self::STORAGE_ENCODING_BINARY: $value = base64_decode($value); if ($value === false) { throw new Exception( pht( 'Failed to base64_decode() value for key "%s".', $key)); } $value = unserialize($value); break; } $state[$key] = $value; } return $state; } /** * Remove conflicts from a list of projects. * * Objects aren't allowed to be tagged with multiple milestones in the same * group, nor projects such that one tag is the ancestor of any other tag. * If the list of PHIDs include mutually exclusive projects, remove the * conflicting projects. * * @param list List of project PHIDs. * @return list List with conflicts removed. */ private function applyProjectConflictRules(array $phids) { if (!$phids) { return array(); } // Overall, the last project in the list wins in cases of conflict (so when // you add something, the thing you just added sticks and removes older // values). // Beyond that, there are two basic cases: // Milestones: An object can't be in "A > Sprint 3" and "A > Sprint 4". // If multiple projects are milestones of the same parent, we only keep the // last one. // Ancestor: You can't be in "A" and "A > B". If "A > B" comes later // in the list, we remove "A" and keep "A > B". If "A" comes later, we // remove "A > B" and keep "A". // Note that it's OK to be in "A > B" and "A > C". There's only a conflict // if one project is an ancestor of another. It's OK to have something // tagged with multiple projects which share a common ancestor, so long as // they are not mutual ancestors. $viewer = PhabricatorUser::getOmnipotentUser(); $projects = id(new PhabricatorProjectQuery()) ->setViewer($viewer) ->withPHIDs(array_keys($phids)) ->execute(); $projects = mpull($projects, null, 'getPHID'); // We're going to build a map from each project with milestones to the last // milestone in the list. This last milestone is the milestone we'll keep. $milestone_map = array(); // We're going to build a set of the projects which have no descendants // later in the list. This allows us to apply both ancestor rules. $ancestor_map = array(); foreach ($phids as $phid => $ignored) { $project = idx($projects, $phid); if (!$project) { continue; } // This is the last milestone we've seen, so set it as the selection for // the project's parent. This might be setting a new value or overwriting // an earlier value. if ($project->isMilestone()) { $parent_phid = $project->getParentProjectPHID(); $milestone_map[$parent_phid] = $phid; } // Since this is the last item in the list we've examined so far, add it // to the set of projects with no later descendants. $ancestor_map[$phid] = $phid; // Remove any ancestors from the set, since this is a later descendant. foreach ($project->getAncestorProjects() as $ancestor) { $ancestor_phid = $ancestor->getPHID(); unset($ancestor_map[$ancestor_phid]); } } // Now that we've built the maps, we can throw away all the projects which // have conflicts. foreach ($phids as $phid => $ignored) { $project = idx($projects, $phid); if (!$project) { // If a PHID is invalid, we just leave it as-is. We could clean it up, // but leaving it untouched is less likely to cause collateral damage. continue; } // If this was a milestone, check if it was the last milestone from its // group in the list. If not, remove it from the list. if ($project->isMilestone()) { $parent_phid = $project->getParentProjectPHID(); if ($milestone_map[$parent_phid] !== $phid) { unset($phids[$phid]); continue; } } // If a later project in the list is a subproject of this one, it will // have removed ancestors from the map. If this project does not point // at itself in the ancestor map, it should be discarded in favor of a // subproject that comes later. if (idx($ancestor_map, $phid) !== $phid) { unset($phids[$phid]); continue; } // If a later project in the list is an ancestor of this one, it will // have added itself to the map. If any ancestor of this project points // at itself in the map, this project should be discarded in favor of // that later ancestor. foreach ($project->getAncestorProjects() as $ancestor) { $ancestor_phid = $ancestor->getPHID(); if (isset($ancestor_map[$ancestor_phid])) { unset($phids[$phid]); continue 2; } } } return $phids; } /** * When the view policy for an object is changed, scramble the secret keys * for attached files to invalidate existing URIs. */ private function scrambleFileSecrets($object) { // If this is a newly created object, we don't need to scramble anything // since it couldn't have been previously published. if ($this->getIsNewObject()) { return; } // If the object is a file itself, scramble it. if ($object instanceof PhabricatorFile) { if ($this->shouldScramblePolicy($object->getViewPolicy())) { $object->scrambleSecret(); $object->save(); } } $phid = $object->getPHID(); $attached_phids = PhabricatorEdgeQuery::loadDestinationPHIDs( $phid, PhabricatorObjectHasFileEdgeType::EDGECONST); if (!$attached_phids) { return; } $omnipotent_viewer = PhabricatorUser::getOmnipotentUser(); $files = id(new PhabricatorFileQuery()) ->setViewer($omnipotent_viewer) ->withPHIDs($attached_phids) ->execute(); foreach ($files as $file) { $view_policy = $file->getViewPolicy(); if ($this->shouldScramblePolicy($view_policy)) { $file->scrambleSecret(); $file->save(); } } } /** * Check if a policy is strong enough to justify scrambling. Objects which * are set to very open policies don't need to scramble their files, and * files with very open policies don't need to be scrambled when associated * objects change. */ private function shouldScramblePolicy($policy) { switch ($policy) { case PhabricatorPolicies::POLICY_PUBLIC: case PhabricatorPolicies::POLICY_USER: return false; } return true; } private function updateWorkboardColumns($object, $const, $old, $new) { // If an object is removed from a project, remove it from any proxy // columns for that project. This allows a task which is moved up from a // milestone to the parent to move back into the "Backlog" column on the // parent workboard. if ($const != PhabricatorProjectObjectHasProjectEdgeType::EDGECONST) { return; } // TODO: This should likely be some future WorkboardInterface. $appears_on_workboards = ($object instanceof ManiphestTask); if (!$appears_on_workboards) { return; } $removed_phids = array_keys(array_diff_key($old, $new)); if (!$removed_phids) { return; } // Find any proxy columns for the removed projects. $proxy_columns = id(new PhabricatorProjectColumnQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withProxyPHIDs($removed_phids) ->execute(); if (!$proxy_columns) { return array(); } $proxy_phids = mpull($proxy_columns, 'getPHID'); $position_table = new PhabricatorProjectColumnPosition(); $conn_w = $position_table->establishConnection('w'); queryfx( $conn_w, 'DELETE FROM %T WHERE objectPHID = %s AND columnPHID IN (%Ls)', $position_table->getTableName(), $object->getPHID(), $proxy_phids); } private function getModularTransactionTypes() { if ($this->modularTypes === null) { $template = $this->object->getApplicationTransactionTemplate(); if ($template instanceof PhabricatorModularTransaction) { $xtypes = $template->newModularTransactionTypes(); foreach ($xtypes as $key => $xtype) { $xtype = clone $xtype; $xtype->setEditor($this); $xtypes[$key] = $xtype; } } else { $xtypes = array(); } $this->modularTypes = $xtypes; } return $this->modularTypes; } private function getModularTransactionType($type) { $types = $this->getModularTransactionTypes(); return idx($types, $type); } public function getCreateObjectTitle($author, $object) { return pht('%s created this object.', $author); } public function getCreateObjectTitleForFeed($author, $object) { return pht('%s created an object: %s.', $author, $object); } /* -( Queue )-------------------------------------------------------------- */ protected function queueTransaction( PhabricatorApplicationTransaction $xaction) { $this->transactionQueue[] = $xaction; return $this; } private function flushTransactionQueue($object) { if (!$this->transactionQueue) { return; } $xactions = $this->transactionQueue; $this->transactionQueue = array(); $editor = $this->newEditorCopy(); return $editor->applyTransactions($object, $xactions); } final protected function newSubEditor( PhabricatorApplicationTransactionEditor $template = null) { $editor = $this->newEditorCopy($template); $editor->parentEditor = $this; $this->subEditors[] = $editor; return $editor; } private function newEditorCopy( PhabricatorApplicationTransactionEditor $template = null) { if ($template === null) { $template = newv(get_class($this), array()); } $editor = id(clone $template) ->setActor($this->getActor()) ->setContentSource($this->getContentSource()) ->setContinueOnNoEffect($this->getContinueOnNoEffect()) ->setContinueOnMissingFields($this->getContinueOnMissingFields()) ->setParentMessageID($this->getParentMessageID()) ->setIsSilent($this->getIsSilent()); if ($this->actingAsPHID !== null) { $editor->setActingAsPHID($this->actingAsPHID); } $editor->mustEncrypt = $this->mustEncrypt; $editor->transactionGroupID = $this->getTransactionGroupID(); return $editor; } /* -( Stamps )------------------------------------------------------------- */ public function newMailStampTemplates($object) { $actor = $this->getActor(); $templates = array(); $extensions = $this->newMailExtensions($object); foreach ($extensions as $extension) { $stamps = $extension->newMailStampTemplates($object); foreach ($stamps as $stamp) { $key = $stamp->getKey(); if (isset($templates[$key])) { throw new Exception( pht( 'Mail extension ("%s") defines a stamp template with the '. 'same key ("%s") as another template. Each stamp template '. 'must have a unique key.', get_class($extension), $key)); } $stamp->setViewer($actor); $templates[$key] = $stamp; } } return $templates; } final public function getMailStamp($key) { if (!isset($this->stampTemplates)) { throw new PhutilInvalidStateException('newMailStampTemplates'); } if (!isset($this->stampTemplates[$key])) { throw new Exception( pht( 'Editor ("%s") has no mail stamp template with provided key ("%s").', get_class($this), $key)); } return $this->stampTemplates[$key]; } private function newMailStamps($object, array $xactions) { $actor = $this->getActor(); $this->stampTemplates = $this->newMailStampTemplates($object); $extensions = $this->newMailExtensions($object); $stamps = array(); foreach ($extensions as $extension) { $extension->newMailStamps($object, $xactions); } return $this->stampTemplates; } private function newMailExtensions($object) { $actor = $this->getActor(); $all_extensions = PhabricatorMailEngineExtension::getAllExtensions(); $extensions = array(); foreach ($all_extensions as $key => $template) { $extension = id(clone $template) ->setViewer($actor) ->setEditor($this); if ($extension->supportsObject($object)) { $extensions[$key] = $extension; } } return $extensions; } private function generateMailStamps($object, $data) { if (!$data || !is_array($data)) { return null; } $templates = $this->newMailStampTemplates($object); foreach ($data as $spec) { if (!is_array($spec)) { continue; } $key = idx($spec, 'key'); if (!isset($templates[$key])) { continue; } $type = idx($spec, 'type'); if ($templates[$key]->getStampType() !== $type) { continue; } $value = idx($spec, 'value'); $templates[$key]->setValueFromDictionary($value); } $results = array(); foreach ($templates as $template) { $value = $template->getValueForRendering(); $rendered = $template->renderStamps($value); if ($rendered === null) { continue; } $rendered = (array)$rendered; foreach ($rendered as $stamp) { $results[] = $stamp; } } natcasesort($results); return $results; } public function getRemovedRecipientPHIDs() { return $this->mailRemovedPHIDs; } private function buildOldRecipientLists($object, $xactions) { // See T4776. Before we start making any changes, build a list of the old // recipients. If a change removes a user from the recipient list for an // object we still want to notify the user about that change. This allows // them to respond if they didn't want to be removed. if (!$this->shouldSendMail($object, $xactions)) { return; } $this->oldTo = $this->getMailTo($object); $this->oldCC = $this->getMailCC($object); return $this; } private function applyOldRecipientLists() { $actor_phid = $this->getActingAsPHID(); // If you took yourself off the recipient list (for example, by // unsubscribing or resigning) assume that you know what you did and // don't need to be notified. // If you just moved from "To" to "Cc" (or vice versa), you're still a // recipient so we don't need to add you back in. $map = array_fuse($this->mailToPHIDs) + array_fuse($this->mailCCPHIDs); foreach ($this->oldTo as $phid) { if ($phid === $actor_phid) { continue; } if (isset($map[$phid])) { continue; } $this->mailToPHIDs[] = $phid; $this->mailRemovedPHIDs[] = $phid; } foreach ($this->oldCC as $phid) { if ($phid === $actor_phid) { continue; } if (isset($map[$phid])) { continue; } $this->mailCCPHIDs[] = $phid; $this->mailRemovedPHIDs[] = $phid; } return $this; } private function queueWebhooks($object, array $xactions) { $hook_viewer = PhabricatorUser::getOmnipotentUser(); $webhook_map = $this->webhookMap; if (!is_array($webhook_map)) { $webhook_map = array(); } // Add any "Firehose" hooks to the list of hooks we're going to call. $firehose_hooks = id(new HeraldWebhookQuery()) ->setViewer($hook_viewer) ->withStatuses( array( HeraldWebhook::HOOKSTATUS_FIREHOSE, )) ->execute(); foreach ($firehose_hooks as $firehose_hook) { // This is "the hook itself is the reason this hook is being called", // since we're including it because it's configured as a firehose // hook. $hook_phid = $firehose_hook->getPHID(); $webhook_map[$hook_phid][] = $hook_phid; } if (!$webhook_map) { return; } // NOTE: We're going to queue calls to disabled webhooks, they'll just // immediately fail in the worker queue. This makes the behavior more // visible. $call_hooks = id(new HeraldWebhookQuery()) ->setViewer($hook_viewer) ->withPHIDs(array_keys($webhook_map)) ->execute(); foreach ($call_hooks as $call_hook) { $trigger_phids = idx($webhook_map, $call_hook->getPHID()); $request = HeraldWebhookRequest::initializeNewWebhookRequest($call_hook) ->setObjectPHID($object->getPHID()) ->setTransactionPHIDs(mpull($xactions, 'getPHID')) ->setTriggerPHIDs($trigger_phids) ->setRetryMode(HeraldWebhookRequest::RETRY_FOREVER) ->setIsSilentAction((bool)$this->getIsSilent()) ->setIsSecureAction((bool)$this->getMustEncrypt()) ->save(); $request->queueCall(); } } private function hasWarnings($object, $xaction) { // TODO: For the moment, this is a very un-modular hack to support // exactly one type of warning (mentioning users on a draft revision) // that we want to show. See PHI433. if (!($object instanceof DifferentialRevision)) { return false; } if (!$object->isDraft()) { return false; } $type = $xaction->getTransactionType(); if ($type != PhabricatorTransactions::TYPE_SUBSCRIBERS) { return false; } // NOTE: This will currently warn even if you're only removing // subscribers. return true; } private function buildHistoryMail(PhabricatorLiskDAO $object) { $viewer = $this->requireActor(); $recipient_phid = $this->getActingAsPHID(); // Load every transaction so we can build a mail message with a complete // history for the object. $query = PhabricatorApplicationTransactionQuery::newQueryForObject($object); $xactions = $query ->setViewer($viewer) ->withObjectPHIDs(array($object->getPHID())) ->execute(); $xactions = array_reverse($xactions); $mail_messages = $this->buildMailWithRecipients( $object, $xactions, array($recipient_phid), array(), array()); $mail = head($mail_messages); // Since the user explicitly requested "!history", force delivery of this // message regardless of their other mail settings. $mail->setForceDelivery(true); return $mail; } public function newAutomaticInlineTransactions( PhabricatorLiskDAO $object, array $inlines, $transaction_type, PhabricatorCursorPagedPolicyAwareQuery $query_template) { $xactions = array(); foreach ($inlines as $inline) { $xactions[] = $object->getApplicationTransactionTemplate() ->setTransactionType($transaction_type) ->attachComment($inline); } $state_xaction = $this->newInlineStateTransaction( $object, $query_template); if ($state_xaction) { $xactions[] = $state_xaction; } return $xactions; } protected function newInlineStateTransaction( PhabricatorLiskDAO $object, PhabricatorCursorPagedPolicyAwareQuery $query_template) { $actor_phid = $this->getActingAsPHID(); $author_phid = $object->getAuthorPHID(); $actor_is_author = ($actor_phid == $author_phid); $state_map = PhabricatorTransactions::getInlineStateMap(); $query = id(clone $query_template) ->setViewer($this->getActor()) ->withFixedStates(array_keys($state_map)); $inlines = array(); $inlines[] = id(clone $query) ->withAuthorPHIDs(array($actor_phid)) ->withHasTransaction(false) ->execute(); if ($actor_is_author) { $inlines[] = id(clone $query) ->withHasTransaction(true) ->execute(); } $inlines = array_mergev($inlines); if (!$inlines) { return null; } $old_value = mpull($inlines, 'getFixedState', 'getPHID'); $new_value = array(); foreach ($old_value as $key => $state) { $new_value[$key] = $state_map[$state]; } // See PHI995. Copy some information about the inlines into the transaction // so we can tailor rendering behavior. In particular, we don't want to // render transactions about users marking their own inlines as "Done". $inline_details = array(); foreach ($inlines as $inline) { $inline_details[$inline->getPHID()] = array( 'authorPHID' => $inline->getAuthorPHID(), ); } return $object->getApplicationTransactionTemplate() ->setTransactionType(PhabricatorTransactions::TYPE_INLINESTATE) ->setIgnoreOnNoEffect(true) ->setMetadataValue('inline.details', $inline_details) ->setOldValue($old_value) ->setNewValue($new_value); } private function requireMFA(PhabricatorLiskDAO $object, array $xactions) { $actor = $this->getActor(); // Let omnipotent editors skip MFA. This is mostly aimed at scripts. if ($actor->isOmnipotent()) { return; } $editor_class = get_class($this); $object_phid = $object->getPHID(); if ($object_phid) { $workflow_key = sprintf( 'editor(%s).phid(%s)', $editor_class, $object_phid); } else { $workflow_key = sprintf( 'editor(%s).new()', $editor_class); } $request = $this->getRequest(); if ($request === null) { $source_type = $this->getContentSource()->getSourceTypeConstant(); $conduit_type = PhabricatorConduitContentSource::SOURCECONST; $is_conduit = ($source_type === $conduit_type); if ($is_conduit) { throw new Exception( pht( 'This transaction group requires MFA to apply, but you can not '. 'provide an MFA response via Conduit. Edit this object via the '. 'web UI.')); } else { throw new Exception( pht( 'This transaction group requires MFA to apply, but the Editor was '. 'not configured with a Request. This workflow can not perform an '. 'MFA check.')); } } $cancel_uri = $this->getCancelURI(); if ($cancel_uri === null) { throw new Exception( pht( 'This transaction group requires MFA to apply, but the Editor was '. 'not configured with a Cancel URI. This workflow can not perform '. 'an MFA check.')); } id(new PhabricatorAuthSessionEngine()) ->setWorkflowKey($workflow_key) ->requireHighSecurityToken($actor, $request, $cancel_uri); foreach ($xactions as $xaction) { $xaction->setIsMFATransaction(true); } } private function newMFATransactions( PhabricatorLiskDAO $object, array $xactions) { $has_engine = ($object instanceof PhabricatorEditEngineMFAInterface); if ($has_engine) { $engine = PhabricatorEditEngineMFAEngine::newEngineForObject($object) ->setViewer($this->getActor()); $require_mfa = $engine->shouldRequireMFA(); $try_mfa = $engine->shouldTryMFA(); } else { $require_mfa = false; $try_mfa = false; } // If the user is mentioning an MFA object on another object or creating // a relationship like "parent" or "child" to this object, we always // allow the edit to move forward without requiring MFA. if ($this->getIsInverseEdgeEditor()) { return $xactions; } if (!$require_mfa) { // If the object hasn't already opted into MFA, see if any of the // transactions want it. if (!$try_mfa) { foreach ($xactions as $xaction) { $type = $xaction->getTransactionType(); $xtype = $this->getModularTransactionType($type); if ($xtype) { $xtype = clone $xtype; $xtype->setStorage($xaction); if ($xtype->shouldTryMFA($object, $xaction)) { $try_mfa = true; break; } } } } if ($try_mfa) { $this->setShouldRequireMFA(true); } return $xactions; } $type_mfa = PhabricatorTransactions::TYPE_MFA; $has_mfa = false; foreach ($xactions as $xaction) { if ($xaction->getTransactionType() === $type_mfa) { $has_mfa = true; break; } } if ($has_mfa) { return $xactions; } $template = $object->getApplicationTransactionTemplate(); $mfa_xaction = id(clone $template) ->setTransactionType($type_mfa) ->setNewValue(true); array_unshift($xactions, $mfa_xaction); return $xactions; } private function getTitleForTextMail( PhabricatorApplicationTransaction $xaction) { $type = $xaction->getTransactionType(); $xtype = $this->getModularTransactionType($type); if ($xtype) { $xtype = clone $xtype; $xtype->setStorage($xaction); $comment = $xtype->getTitleForTextMail(); if ($comment !== false) { return $comment; } } return $xaction->getTitleForTextMail(); } private function getTitleForHTMLMail( PhabricatorApplicationTransaction $xaction) { $type = $xaction->getTransactionType(); $xtype = $this->getModularTransactionType($type); if ($xtype) { $xtype = clone $xtype; $xtype->setStorage($xaction); $comment = $xtype->getTitleForHTMLMail(); if ($comment !== false) { return $comment; } } return $xaction->getTitleForHTMLMail(); } private function getBodyForTextMail( PhabricatorApplicationTransaction $xaction) { $type = $xaction->getTransactionType(); $xtype = $this->getModularTransactionType($type); if ($xtype) { $xtype = clone $xtype; $xtype->setStorage($xaction); $comment = $xtype->getBodyForTextMail(); if ($comment !== false) { return $comment; } } return $xaction->getBodyForMail(); } private function isLockOverrideTransaction( PhabricatorApplicationTransaction $xaction) { // See PHI1209. When an object is locked, certain types of transactions // can still be applied without requiring a policy check, like subscribing // or unsubscribing. We don't want these transactions to show the "Lock // Override" icon in the transaction timeline. // We could test if a transaction did no direct policy checks, but it may // have done additional policy checks during validation, so this is not a // reliable test (and could cause false negatives, where edits which did // override a lock are not marked properly). // For now, do this in a narrow way and just check against a hard-coded // list of non-override transaction situations. Some day, this should // likely be modularized. // Inverse edge edits don't interact with locks. if ($this->getIsInverseEdgeEditor()) { return false; } // For now, all edits other than subscribes always override locks. $type = $xaction->getTransactionType(); if ($type !== PhabricatorTransactions::TYPE_SUBSCRIBERS) { return true; } // Subscribes override locks if they affect any users other than the // acting user. $acting_phid = $this->getActingAsPHID(); $old = array_fuse($xaction->getOldValue()); $new = array_fuse($xaction->getNewValue()); $add = array_diff_key($new, $old); $rem = array_diff_key($old, $new); $all = $add + $rem; foreach ($all as $phid) { if ($phid !== $acting_phid) { return true; } } return false; } /* -( Extensions )--------------------------------------------------------- */ private function validateTransactionsWithExtensions( PhabricatorLiskDAO $object, array $xactions) { $errors = array(); $extensions = $this->getEditorExtensions(); foreach ($extensions as $extension) { $extension_errors = $extension ->setObject($object) ->validateTransactions($object, $xactions); assert_instances_of( $extension_errors, 'PhabricatorApplicationTransactionValidationError'); $errors[] = $extension_errors; } return array_mergev($errors); } private function getEditorExtensions() { if ($this->extensions === null) { $this->extensions = $this->newEditorExtensions(); } return $this->extensions; } private function newEditorExtensions() { $extensions = PhabricatorEditorExtension::getAllExtensions(); $actor = $this->getActor(); $object = $this->object; foreach ($extensions as $key => $extension) { $extension = id(clone $extension) ->setViewer($actor) ->setEditor($this) ->setObject($object); if (!$extension->supportsObject($this, $object)) { unset($extensions[$key]); continue; } $extensions[$key] = $extension; } return $extensions; } }