diff --git a/src/applications/differential/editor/DifferentialRevisionEditor.php b/src/applications/differential/editor/DifferentialRevisionEditor.php index 5c156d04be..15e3f2c6bd 100644 --- a/src/applications/differential/editor/DifferentialRevisionEditor.php +++ b/src/applications/differential/editor/DifferentialRevisionEditor.php @@ -1,910 +1,674 @@ aphrontRequestForEventDispatch = $request; - return $this; - } - - public function getAphrontRequestForEventDispatch() { - return $this->aphrontRequestForEventDispatch; - } public function __construct(DifferentialRevision $revision) { $this->revision = $revision; $this->isCreate = !($revision->getID()); } - public static function newRevisionFromConduitWithDiff( - array $fields, - DifferentialDiff $diff, - PhabricatorUser $actor) { - - $revision = DifferentialRevision::initializeNewRevision($actor); - $revision->setPHID($revision->generatePHID()); - - $editor = new DifferentialRevisionEditor($revision); - $editor->setActor($actor); - $editor->addDiff($diff, null); - $editor->copyFieldsFromConduit($fields); - - $editor->save(); - - return $revision; - } - - public function copyFieldsFromConduit(array $fields) { - - $actor = $this->getActor(); - $revision = $this->revision; - $revision->loadRelationships(); - - $all_fields = DifferentialFieldSelector::newSelector() - ->getFieldSpecifications(); - - $aux_fields = array(); - foreach ($all_fields as $aux_field) { - $aux_field->setRevision($revision); - $aux_field->setDiff($this->diff); - $aux_field->setUser($actor); - if ($aux_field->shouldAppearOnCommitMessage()) { - $aux_fields[$aux_field->getCommitMessageKey()] = $aux_field; - } - } - - foreach ($fields as $field => $value) { - if (empty($aux_fields[$field])) { - throw new Exception( - "Parsed commit message contains unrecognized field '{$field}'."); - } - $aux_fields[$field]->setValueFromParsedCommitMessage($value); - } - - foreach ($aux_fields as $aux_field) { - $aux_field->validateField(); - } - - $this->setAuxiliaryFields($all_fields); - } - public function setAuxiliaryFields(array $auxiliary_fields) { assert_instances_of($auxiliary_fields, 'DifferentialFieldSpecification'); $this->auxiliaryFields = $auxiliary_fields; return $this; } public function getRevision() { return $this->revision; } public function setReviewers(array $reviewers) { $this->reviewers = $reviewers; return $this; } public function setCCPHIDs(array $cc) { $this->cc = $cc; return $this; } public function setContentSource(PhabricatorContentSource $content_source) { $this->contentSource = $content_source; return $this; } public function addDiff(DifferentialDiff $diff, $comments) { if ($diff->getRevisionID() && $diff->getRevisionID() != $this->getRevision()->getID()) { $diff_id = (int)$diff->getID(); $targ_id = (int)$this->getRevision()->getID(); $real_id = (int)$diff->getRevisionID(); throw new Exception( "Can not attach diff #{$diff_id} to Revision D{$targ_id}, it is ". "already attached to D{$real_id}."); } $this->diff = $diff; $this->comments = $comments; $repository = id(new DifferentialRepositoryLookup()) ->setViewer($this->getActor()) ->setDiff($diff) ->lookupRepository(); if ($repository) { $this->getRevision()->setRepositoryPHID($repository->getPHID()); } return $this; } protected function getDiff() { return $this->diff; } protected function getComments() { return $this->comments; } protected function getActorPHID() { return $this->getActor()->getPHID(); } public function isNewRevision() { return !$this->getRevision()->getID(); } public function save() { $revision = $this->getRevision(); $is_new = $this->isNewRevision(); $revision->loadRelationships(); if ($this->reviewers === null) { $this->reviewers = $revision->getReviewers(); } if ($this->cc === null) { $this->cc = $revision->getCCPHIDs(); } if ($is_new) { $content_blocks = array(); foreach ($this->auxiliaryFields as $field) { if ($field->shouldExtractMentions()) { $content_blocks[] = $field->renderValueForCommitMessage(false); } } $phids = PhabricatorMarkupEngine::extractPHIDsFromMentions( $content_blocks); $this->cc = array_unique(array_merge($this->cc, $phids)); } $diff = $this->getDiff(); if ($diff) { $revision->setLineCount($diff->getLineCount()); } // Save the revision, to generate its ID and PHID if it is new. We need // the ID/PHID in order to record them in Herald transcripts, but don't // want to hold a transaction open while running Herald because it is // potentially somewhat slow. The downside is that we may end up with a // saved revision/diff pair without appropriate CCs. We could be better // about this -- for example: // // - Herald can't affect reviewers, so we could compute them before // opening the transaction and then save them in the transaction. // - Herald doesn't *really* need PHIDs to compute its effects, we could // run it before saving these objects and then hand over the PHIDs later. // // But this should address the problem of orphaned revisions, which is // currently the only problem we experience in practice. $revision->openTransaction(); if ($diff) { $revision->setBranchName($diff->getBranch()); $revision->setArcanistProjectPHID($diff->getArcanistProjectPHID()); } $revision->save(); if ($diff) { $diff->setRevisionID($revision->getID()); $diff->save(); } $revision->saveTransaction(); // We're going to build up three dictionaries: $add, $rem, and $stable. The // $add dictionary has added reviewers/CCs. The $rem dictionary has // reviewers/CCs who have been removed, and the $stable array is // reviewers/CCs who haven't changed. We're going to send new reviewers/CCs // a different ("welcome") email than we send stable reviewers/CCs. $old = array( 'rev' => array_fill_keys($revision->getReviewers(), true), 'ccs' => array_fill_keys($revision->getCCPHIDs(), true), ); $xscript_header = null; $xscript_uri = null; $new = array( 'rev' => array_fill_keys($this->reviewers, true), 'ccs' => array_fill_keys($this->cc, true), ); $rem_ccs = array(); $xscript_phid = null; if ($diff) { $unsubscribed_phids = PhabricatorEdgeQuery::loadDestinationPHIDs( $revision->getPHID(), PhabricatorEdgeConfig::TYPE_OBJECT_HAS_UNSUBSCRIBER); $adapter = HeraldDifferentialRevisionAdapter::newLegacyAdapter( $revision, $diff); $adapter->setExplicitCCs($new['ccs']); $adapter->setExplicitReviewers($new['rev']); $adapter->setForbiddenCCs($unsubscribed_phids); $adapter->setIsNewObject($is_new); $xscript = HeraldEngine::loadAndApplyRules($adapter); $xscript_uri = '/herald/transcript/'.$xscript->getID().'/'; $xscript_phid = $xscript->getPHID(); $xscript_header = $xscript->getXHeraldRulesHeader(); $xscript_header = HeraldTranscript::saveXHeraldRulesHeader( $revision->getPHID(), $xscript_header); $sub = array( 'rev' => $adapter->getReviewersAddedByHerald(), 'ccs' => $adapter->getCCsAddedByHerald(), ); $rem_ccs = $adapter->getCCsRemovedByHerald(); $blocking_reviewers = array_keys( $adapter->getBlockingReviewersAddedByHerald()); HarbormasterBuildable::applyBuildPlans( $diff->getPHID(), $revision->getPHID(), $adapter->getBuildPlans()); } else { $sub = array( 'rev' => array(), 'ccs' => array(), ); $blocking_reviewers = array(); } // Remove any CCs which are prevented by Herald rules. $sub['ccs'] = array_diff_key($sub['ccs'], $rem_ccs); $new['ccs'] = array_diff_key($new['ccs'], $rem_ccs); $add = array(); $rem = array(); $stable = array(); foreach (array('rev', 'ccs') as $key) { $add[$key] = array(); if ($new[$key] !== null) { $add[$key] += array_diff_key($new[$key], $old[$key]); } $add[$key] += array_diff_key($sub[$key], $old[$key]); $combined = $sub[$key]; if ($new[$key] !== null) { $combined += $new[$key]; } $rem[$key] = array_diff_key($old[$key], $combined); $stable[$key] = array_diff_key($old[$key], $add[$key] + $rem[$key]); } // Prevent Herald rules from adding a revision's owner as a reviewer. unset($add['rev'][$revision->getAuthorPHID()]); self::updateReviewers( $revision, $this->getActor(), array_keys($add['rev']), array_keys($rem['rev']), $blocking_reviewers); // We want to attribute new CCs to a "reasonPHID", representing the reason // they were added. This is either a user (if some user explicitly CCs // them, or uses "Add CCs...") or a Herald transcript PHID, indicating that // they were added by a Herald rule. if ($add['ccs'] || $rem['ccs']) { $reasons = array(); foreach ($add['ccs'] as $phid => $ignored) { if (empty($new['ccs'][$phid])) { $reasons[$phid] = $xscript_phid; } else { $reasons[$phid] = $this->getActorPHID(); } } foreach ($rem['ccs'] as $phid => $ignored) { if (empty($new['ccs'][$phid])) { $reasons[$phid] = $this->getActorPHID(); } else { $reasons[$phid] = $xscript_phid; } } } else { $reasons = $this->getActorPHID(); } self::alterCCs( $revision, $this->cc, array_keys($rem['ccs']), array_keys($add['ccs']), $reasons); $this->updateAuxiliaryFields(); // Add the author and users included from Herald rules to the relevant set // of users so they get a copy of the email. if (!$this->silentUpdate) { if ($is_new) { $add['rev'][$this->getActorPHID()] = true; if ($diff) { $add['rev'] += $adapter->getEmailPHIDsAddedByHerald(); } } else { $stable['rev'][$this->getActorPHID()] = true; if ($diff) { $stable['rev'] += $adapter->getEmailPHIDsAddedByHerald(); } } } $mail = array(); $phids = array($this->getActorPHID()); $handles = id(new PhabricatorHandleQuery()) ->setViewer($this->getActor()) ->withPHIDs($phids) ->execute(); $actor_handle = $handles[$this->getActorPHID()]; $changesets = null; $old_status = $revision->getStatus(); if ($diff) { $changesets = $diff->loadChangesets(); // TODO: This should probably be in DifferentialFeedbackEditor? if (!$is_new) { $this->createComment(); $mail[] = id(new DifferentialNewDiffMail( $revision, $actor_handle, $changesets)) ->setActor($this->getActor()) ->setIsFirstMailAboutRevision(false) ->setIsFirstMailToRecipients(false) ->setCommentText($this->getComments()) ->setToPHIDs(array_keys($stable['rev'])) ->setCCPHIDs(array_keys($stable['ccs'])); } // Save the changes we made above. $diff->setDescription(preg_replace('/\n.*/s', '', $this->getComments())); $diff->save(); - $this->updateAffectedPathTable($revision, $diff, $changesets); - $this->updateRevisionHashTable($revision, $diff); - // An updated diff should require review, as long as it's not closed // or accepted. The "accepted" status is "sticky" to encourage courtesy // re-diffs after someone accepts with minor changes/suggestions. $status = $revision->getStatus(); if ($status != ArcanistDifferentialRevisionStatus::CLOSED && $status != ArcanistDifferentialRevisionStatus::ACCEPTED) { $revision->setStatus(ArcanistDifferentialRevisionStatus::NEEDS_REVIEW); } } else { $diff = $revision->loadActiveDiff(); if ($diff) { $changesets = $diff->loadChangesets(); } else { $changesets = array(); } } $revision->save(); // If the actor just deleted all the blocking/rejected reviewers, we may // be able to put the revision into "accepted". switch ($revision->getStatus()) { case ArcanistDifferentialRevisionStatus::NEEDS_REVISION: case ArcanistDifferentialRevisionStatus::CHANGES_PLANNED: case ArcanistDifferentialRevisionStatus::NEEDS_REVIEW: $revision = self::updateAcceptedStatus( $this->getActor(), $revision); break; } $event_data = array( 'revision_id' => $revision->getID(), 'revision_phid' => $revision->getPHID(), 'revision_name' => $revision->getTitle(), 'revision_author_phid' => $revision->getAuthorPHID(), 'action' => $is_new ? DifferentialAction::ACTION_CREATE : DifferentialAction::ACTION_UPDATE, 'feedback_content' => $is_new ? phutil_utf8_shorten($revision->getSummary(), 140) : $this->getComments(), 'actor_phid' => $revision->getAuthorPHID(), ); $mailed_phids = array(); if (!$this->silentUpdate) { $revision->loadRelationships(); if ($add['rev']) { $message = id(new DifferentialNewDiffMail( $revision, $actor_handle, $changesets)) ->setActor($this->getActor()) ->setIsFirstMailAboutRevision($is_new) ->setIsFirstMailToRecipients(true) ->setToPHIDs(array_keys($add['rev'])); if ($is_new) { // The first time we send an email about a revision, put the CCs in // the "CC:" field of the same "Review Requested" email that reviewers // get, so you don't get two initial emails if you're on a list that // is CC'd. $message->setCCPHIDs(array_keys($add['ccs'])); } $mail[] = $message; } // If we added CCs, we want to send them an email, but only if they were // not already a reviewer and were not added as one (in these cases, they // got a "NewDiff" mail, either in the past or just a moment ago). You can // still get two emails, but only if a revision is updated and you are // added as a reviewer at the same time a list you are on is added as a // CC, which is rare and reasonable. $implied_ccs = self::getImpliedCCs($revision); $implied_ccs = array_fill_keys($implied_ccs, true); $add['ccs'] = array_diff_key($add['ccs'], $implied_ccs); if (!$is_new && $add['ccs']) { $mail[] = id(new DifferentialCCWelcomeMail( $revision, $actor_handle, $changesets)) ->setActor($this->getActor()) ->setIsFirstMailToRecipients(true) ->setToPHIDs(array_keys($add['ccs'])); } foreach ($mail as $message) { $message->setHeraldTranscriptURI($xscript_uri); $message->setXHeraldRulesHeader($xscript_header); $message->send(); $mailed_phids[] = $message->getRawMail()->buildRecipientList(); } $mailed_phids = array_mergev($mailed_phids); } id(new PhabricatorFeedStoryPublisher()) ->setStoryType('PhabricatorFeedStoryDifferential') ->setStoryData($event_data) ->setStoryTime(time()) ->setStoryAuthorPHID($revision->getAuthorPHID()) ->setRelatedPHIDs( array( $revision->getPHID(), $revision->getAuthorPHID(), )) ->setPrimaryObjectPHID($revision->getPHID()) ->setSubscribedPHIDs( array_merge( array($revision->getAuthorPHID()), $revision->getReviewers(), $revision->getCCPHIDs())) ->setMailRecipientPHIDs($mailed_phids) ->publish(); id(new PhabricatorSearchIndexer()) ->queueDocumentForIndexing($revision->getPHID()); } protected static function alterCCs( DifferentialRevision $revision, array $stable_phids, array $rem_phids, array $add_phids, $reason_phid) { $dont_add = self::getImpliedCCs($revision); $add_phids = array_diff($add_phids, $dont_add); id(new PhabricatorSubscriptionsEditor()) ->setActor(PhabricatorUser::getOmnipotentUser()) ->setObject($revision) ->subscribeExplicit($add_phids) ->unsubscribe($rem_phids) ->save(); } private static function getImpliedCCs(DifferentialRevision $revision) { return array_merge( $revision->getReviewers(), array($revision->getAuthorPHID())); } public static function updateReviewers( DifferentialRevision $revision, PhabricatorUser $actor, array $add_phids, array $remove_phids, array $blocking_phids = array()) { $reviewers = $revision->getReviewers(); $editor = id(new PhabricatorEdgeEditor()) ->setActor($actor); $reviewer_phids_map = array_fill_keys($reviewers, true); $blocking_phids = array_fuse($blocking_phids); foreach ($add_phids as $phid) { // Adding an already existing edge again would have cause memory loss // That is, the previous state for that reviewer would be lost if (isset($reviewer_phids_map[$phid])) { // TODO: If we're writing a blocking edge, we should overwrite an // existing weaker edge (like "added" or "commented"), just not a // stronger existing edge. continue; } if (isset($blocking_phids[$phid])) { $status = DifferentialReviewerStatus::STATUS_BLOCKING; } else { $status = DifferentialReviewerStatus::STATUS_ADDED; } $options = array( 'data' => array( 'status' => $status, ) ); $editor->addEdge( $revision->getPHID(), PhabricatorEdgeConfig::TYPE_DREV_HAS_REVIEWER, $phid, $options); } foreach ($remove_phids as $phid) { $editor->removeEdge( $revision->getPHID(), PhabricatorEdgeConfig::TYPE_DREV_HAS_REVIEWER, $phid); } $editor->save(); } private function createComment() { $template = id(new DifferentialComment()) ->setAuthorPHID($this->getActorPHID()) ->setRevision($this->revision); if ($this->contentSource) { $content_source = $this->contentSource; } else { $content_source = PhabricatorContentSource::newForSource( PhabricatorContentSource::SOURCE_LEGACY, array()); } $template->setContentSource($content_source); // Write the "update active diff" transaction. id(clone $template) ->setAction(DifferentialAction::ACTION_UPDATE) ->setMetadata( array( DifferentialComment::METADATA_DIFF_ID => $this->getDiff()->getID(), )) ->save(); // If we have a comment, write the "add a comment" transaction. if (strlen($this->getComments())) { id(clone $template) ->setAction(DifferentialAction::ACTION_COMMENT) ->setContent($this->getComments()) ->save(); } } private function updateAuxiliaryFields() { $aux_map = array(); foreach ($this->auxiliaryFields as $aux_field) { $key = $aux_field->getStorageKey(); if ($key !== null) { $val = $aux_field->getValueForStorage(); $aux_map[$key] = $val; } } if (!$aux_map) { return; } $revision = $this->revision; $fields = id(new DifferentialCustomFieldStorage())->loadAllWhere( 'objectPHID = %s', $revision->getPHID()); $fields = mpull($fields, null, 'getFieldIndex'); foreach ($aux_map as $key => $val) { $index = PhabricatorHash::digestForIndex($key); $obj = idx($fields, $index); if (!strlen($val)) { // If the new value is empty, just delete the old row if one exists and // don't add a new row if it doesn't. if ($obj) { $obj->delete(); } } else { if (!$obj) { $obj = new DifferentialCustomFieldStorage(); $obj->setObjectPHID($revision->getPHID()); $obj->setFieldIndex($index); } if ($obj->getFieldValue() !== $val) { $obj->setFieldValue($val); $obj->save(); } } } } - /** - * 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, - array $changesets) { - assert_instances_of($changesets, 'DifferentialChangeset'); - - $project = $diff->loadArcanistProject(); - if (!$project) { - // Probably an old revision from before projects. - return; - } - - $repository = $project->loadRepository(); - if (!$repository) { - // Probably no project <-> repository link, or the repository where the - // project 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)); - } - } - - $paths = array(); - foreach ($changesets as $changeset) { - $paths[] = $path_prefix.'/'.$changeset->getFilename(); - } - - // 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 %Q', - $table->getTableName(), - implode(', ', $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 %Q', - ArcanistDifferentialRevisionHash::TABLE_NAME, - implode(', ', $sql)); - } - } /** * Try to move a revision to "accepted". We look for: * * - at least one accepting reviewer who is a user; and * - no rejects; and * - no blocking reviewers. */ public static function updateAcceptedStatus( PhabricatorUser $viewer, DifferentialRevision $revision) { $revision = id(new DifferentialRevisionQuery()) ->setViewer($viewer) ->withIDs(array($revision->getID())) ->needRelationships(true) ->needReviewerStatus(true) ->needReviewerAuthority(true) ->executeOne(); $has_user_accept = false; foreach ($revision->getReviewerStatus() as $reviewer) { $status = $reviewer->getStatus(); if ($status == DifferentialReviewerStatus::STATUS_BLOCKING) { // We have a blocking reviewer, so just leave the revision in its // existing state. return $revision; } if ($status == DifferentialReviewerStatus::STATUS_REJECTED) { // We have a rejecting reviewer, so leave the revisoin as is. return $revision; } if ($reviewer->isUser()) { if ($status == DifferentialReviewerStatus::STATUS_ACCEPTED) { $has_user_accept = true; } } } if ($has_user_accept) { $revision ->setStatus(ArcanistDifferentialRevisionStatus::ACCEPTED) ->save(); } return $revision; } } diff --git a/src/applications/differential/editor/DifferentialTransactionEditor.php b/src/applications/differential/editor/DifferentialTransactionEditor.php index 0e094255db..06cb5a01f1 100644 --- a/src/applications/differential/editor/DifferentialTransactionEditor.php +++ b/src/applications/differential/editor/DifferentialTransactionEditor.php @@ -1,1303 +1,1494 @@ 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 getTransactionTypes() { $types = parent::getTransactionTypes(); $types[] = PhabricatorTransactions::TYPE_COMMENT; $types[] = PhabricatorTransactions::TYPE_EDGE; $types[] = PhabricatorTransactions::TYPE_VIEW_POLICY; $types[] = PhabricatorTransactions::TYPE_EDIT_POLICY; $types[] = DifferentialTransaction::TYPE_ACTION; $types[] = DifferentialTransaction::TYPE_INLINE; $types[] = DifferentialTransaction::TYPE_STATUS; $types[] = DifferentialTransaction::TYPE_UPDATE; return $types; } protected function getCustomTransactionOldValue( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case PhabricatorTransactions::TYPE_VIEW_POLICY: return $object->getViewPolicy(); case PhabricatorTransactions::TYPE_EDIT_POLICY: return $object->getEditPolicy(); case DifferentialTransaction::TYPE_ACTION: return null; case DifferentialTransaction::TYPE_INLINE: return null; case DifferentialTransaction::TYPE_UPDATE: if ($this->getIsNewObject()) { return null; } else { return $object->getActiveDiff()->getPHID(); } } return parent::getCustomTransactionOldValue($object, $xaction); } protected function getCustomTransactionNewValue( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case PhabricatorTransactions::TYPE_VIEW_POLICY: case PhabricatorTransactions::TYPE_EDIT_POLICY: case DifferentialTransaction::TYPE_ACTION: case DifferentialTransaction::TYPE_UPDATE: return $xaction->getNewValue(); case DifferentialTransaction::TYPE_INLINE: return null; } return parent::getCustomTransactionNewValue($object, $xaction); } protected function transactionHasEffect( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case DifferentialTransaction::TYPE_INLINE: return $xaction->hasComment(); case DifferentialTransaction::TYPE_ACTION: $status_closed = ArcanistDifferentialRevisionStatus::CLOSED; $status_abandoned = ArcanistDifferentialRevisionStatus::ABANDONED; $status_review = ArcanistDifferentialRevisionStatus::NEEDS_REVIEW; $status_revision = ArcanistDifferentialRevisionStatus::NEEDS_REVISION; $status_plan = ArcanistDifferentialRevisionStatus::CHANGES_PLANNED; $action_type = $xaction->getNewValue(); switch ($action_type) { case DifferentialAction::ACTION_ACCEPT: case DifferentialAction::ACTION_REJECT: if ($action_type == DifferentialAction::ACTION_ACCEPT) { $new_status = DifferentialReviewerStatus::STATUS_ACCEPTED; } else { $new_status = DifferentialReviewerStatus::STATUS_REJECTED; } $actor = $this->getActor(); $actor_phid = $actor->getPHID(); // These transactions can cause effects in two ways: by altering the // status of an existing reviewer; or by adding the actor as a new // reviewer. $will_add_reviewer = true; foreach ($object->getReviewerStatus() as $reviewer) { if ($reviewer->hasAuthority($actor)) { if ($reviewer->getStatus() != $new_status) { return true; } } if ($reviewer->getReviewerPHID() == $actor_phid) { $will_add_reviwer = false; } } return $will_add_reviewer; case DifferentialAction::ACTION_CLOSE: return ($object->getStatus() != $status_closed); case DifferentialAction::ACTION_ABANDON: return ($object->getStatus() != $status_abandoned); case DifferentialAction::ACTION_RECLAIM: return ($object->getStatus() == $status_abandoned); case DifferentialAction::ACTION_REOPEN: return ($object->getStatus() == $status_closed); case DifferentialAction::ACTION_RETHINK: return ($object->getStatus() != $status_plan); case DifferentialAction::ACTION_REQUEST: return ($object->getStatus() != $status_review); case DifferentialAction::ACTION_RESIGN: $actor_phid = $this->getActor()->getPHID(); foreach ($object->getReviewerStatus() as $reviewer) { if ($reviewer->getReviewerPHID() == $actor_phid) { return true; } } return false; case DifferentialAction::ACTION_CLAIM: $actor_phid = $this->getActor()->getPHID(); return ($actor_phid != $object->getAuthorPHID()); } } return parent::transactionHasEffect($object, $xaction); } protected function applyCustomInternalTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { $status_review = ArcanistDifferentialRevisionStatus::NEEDS_REVIEW; $status_revision = ArcanistDifferentialRevisionStatus::NEEDS_REVISION; $status_plan = ArcanistDifferentialRevisionStatus::CHANGES_PLANNED; switch ($xaction->getTransactionType()) { case PhabricatorTransactions::TYPE_VIEW_POLICY: $object->setViewPolicy($xaction->getNewValue()); return; case PhabricatorTransactions::TYPE_EDIT_POLICY: $object->setEditPolicy($xaction->getNewValue()); return; case PhabricatorTransactions::TYPE_SUBSCRIBERS: case PhabricatorTransactions::TYPE_COMMENT: case DifferentialTransaction::TYPE_INLINE: return; case PhabricatorTransactions::TYPE_EDGE: return; case DifferentialTransaction::TYPE_UPDATE: if (!$this->getIsCloseByCommit()) { $object->setStatus($status_review); } // TODO: Update the `diffPHID` once we add that. return; case DifferentialTransaction::TYPE_ACTION: switch ($xaction->getNewValue()) { case DifferentialAction::ACTION_RESIGN: case DifferentialAction::ACTION_ACCEPT: case DifferentialAction::ACTION_REJECT: // These have no direct effects, and affect review status only // indirectly by altering reviewers with TYPE_EDGE transactions. return; case DifferentialAction::ACTION_ABANDON: $object->setStatus(ArcanistDifferentialRevisionStatus::ABANDONED); return; case DifferentialAction::ACTION_RETHINK: $object->setStatus($status_plan); return; case DifferentialAction::ACTION_RECLAIM: $object->setStatus($status_review); return; case DifferentialAction::ACTION_REOPEN: $object->setStatus($status_review); return; case DifferentialAction::ACTION_REQUEST: $object->setStatus($status_review); return; case DifferentialAction::ACTION_CLOSE: $object->setStatus(ArcanistDifferentialRevisionStatus::CLOSED); return; case DifferentialAction::ACTION_CLAIM: $object->setAuthorPHID($this->getActor()->getPHID()); return; } break; } return parent::applyCustomInternalTransaction($object, $xaction); } protected function expandTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { $actor = $this->getActor(); $actor_phid = $actor->getPHID(); $type_edge = PhabricatorTransactions::TYPE_EDGE; $edge_reviewer = PhabricatorEdgeConfig::TYPE_DREV_HAS_REVIEWER; $edge_ref_task = PhabricatorEdgeConfig::TYPE_DREV_HAS_RELATED_TASK; $results = parent::expandTransaction($object, $xaction); switch ($xaction->getTransactionType()) { case DifferentialTransaction::TYPE_UPDATE: if ($this->getIsCloseByCommit()) { // Don't bother with any of this if this update is a side effect of // commit detection. break; } $new_accept = DifferentialReviewerStatus::STATUS_ACCEPTED; $new_reject = DifferentialReviewerStatus::STATUS_REJECTED; $old_accept = DifferentialReviewerStatus::STATUS_ACCEPTED_OLDER; $old_reject = DifferentialReviewerStatus::STATUS_REJECTED_OLDER; // When a revision is updated, change all "reject" to "rejected older // revision". This means we won't immediately push the update back into // "needs review", but outstanding rejects will still block it from // moving to "accepted". $edits = array(); foreach ($object->getReviewerStatus() as $reviewer) { if ($reviewer->getStatus() == $new_reject) { $edits[$reviewer->getReviewerPHID()] = array( 'data' => array( 'status' => $old_reject, ), ); } // TODO: If sticky accept is off, do a similar update for accepts. } if ($edits) { $results[] = id(new DifferentialTransaction()) ->setTransactionType($type_edge) ->setMetadataValue('edge:type', $edge_reviewer) ->setIgnoreOnNoEffect(true) ->setNewValue(array('+' => $edits)); } // 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 = 'PhabricatorApplicationManiphest'; if (PhabricatorApplication::isClassInstalled($maniphest)) { $diff = $this->loadDiff($xaction->getNewValue()); - if ($diff) { - $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))); - } + $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 PhabricatorTransactions::TYPE_COMMENT: // When a user leaves a comment, upgrade their reviewer status from // "added" to "commented" if they're also a reviewer. We may further // upgrade this based on other actions in the transaction group. $status_added = DifferentialReviewerStatus::STATUS_ADDED; $status_commented = DifferentialReviewerStatus::STATUS_COMMENTED; $data = array( 'status' => $status_commented, ); $edits = array(); foreach ($object->getReviewerStatus() as $reviewer) { if ($reviewer->getReviewerPHID() == $actor_phid) { if ($reviewer->getStatus() == $status_added) { $edits[$actor_phid] = array( 'data' => $data, ); } } } if ($edits) { $results[] = id(new DifferentialTransaction()) ->setTransactionType($type_edge) ->setMetadataValue('edge:type', $edge_reviewer) ->setIgnoreOnNoEffect(true) ->setNewValue(array('+' => $edits)); } break; case DifferentialTransaction::TYPE_ACTION: $action_type = $xaction->getNewValue(); switch ($action_type) { case DifferentialAction::ACTION_ACCEPT: case DifferentialAction::ACTION_REJECT: if ($action_type == DifferentialAction::ACTION_ACCEPT) { $data = array( 'status' => DifferentialReviewerStatus::STATUS_ACCEPTED, ); } else { $data = array( 'status' => DifferentialReviewerStatus::STATUS_REJECTED, ); } $edits = array(); foreach ($object->getReviewerStatus() as $reviewer) { if ($reviewer->hasAuthority($actor)) { $edits[$reviewer->getReviewerPHID()] = array( 'data' => $data, ); } } // Also either update or add the actor themselves as a reviewer. $edits[$actor_phid] = array( 'data' => $data, ); $results[] = id(new DifferentialTransaction()) ->setTransactionType($type_edge) ->setMetadataValue('edge:type', $edge_reviewer) ->setIgnoreOnNoEffect(true) ->setNewValue(array('+' => $edits)); break; case DifferentialAction::ACTION_CLAIM: // If the user is commandeering, add the previous owner as a // reviewer and remove the actor. $edits = array( '-' => array( $actor_phid => $actor_phid, ), ); $owner_phid = $object->getAuthorPHID(); if ($owner_phid) { $reviewer = new DifferentialReviewer( $owner_phid, array( 'status' => DifferentialReviewerStatus::STATUS_ADDED, )); $edits['+'] = array( $owner_phid => array( 'data' => $reviewer->getEdgeData(), ), ); } $results[] = id(new DifferentialTransaction()) ->setTransactionType($type_edge) ->setMetadataValue('edge:type', $edge_reviewer) ->setIgnoreOnNoEffect(true) ->setNewValue($edits); break; case DifferentialAction::ACTION_RESIGN: // If the user is resigning, add a separate reviewer edit // transaction which removes them as a reviewer. $results[] = id(new DifferentialTransaction()) ->setTransactionType($type_edge) ->setMetadataValue('edge:type', $edge_reviewer) ->setIgnoreOnNoEffect(true) ->setNewValue( array( '-' => array( $actor_phid => $actor_phid, ), )); break; } break; } return $results; } protected function applyCustomExternalTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { case PhabricatorTransactions::TYPE_VIEW_POLICY: case PhabricatorTransactions::TYPE_EDIT_POLICY: return; case PhabricatorTransactions::TYPE_SUBSCRIBERS: case PhabricatorTransactions::TYPE_EDGE: case PhabricatorTransactions::TYPE_COMMENT: case DifferentialTransaction::TYPE_ACTION: case DifferentialTransaction::TYPE_INLINE: return; case DifferentialTransaction::TYPE_UPDATE: // Now that we're inside the transaction, do a final check. $diff = $this->loadDiff($xaction->getNewValue()); // TODO: It would be slightly cleaner to just revalidate this // transaction somehow using the same validation code, but that's // not easy to do at the moment. if (!$diff) { throw new Exception(pht('Diff does not exist!')); } else { $revision_id = $diff->getRevisionID(); if ($revision_id && ($revision_id != $object->getID())) { throw new Exception( pht( 'Diff is already attached to another revision. You lost '. 'a race?')); } } $diff->setRevisionID($object->getID()); $diff->save(); $object->setLineCount($diff->getLineCount()); $object->setRepositoryPHID($diff->getRepositoryPHID()); return; } return parent::applyCustomExternalTransaction($object, $xaction); } protected function mergeEdgeData($type, array $u, array $v) { $result = parent::mergeEdgeData($type, $u, $v); switch ($type) { case PhabricatorEdgeConfig::TYPE_DREV_HAS_REVIEWER: // When the same reviewer has their status updated by multiple // transactions, we want the strongest status to win. An example of // this is when a user adds a comment and also accepts a revision which // they are a reviewer on. The comment creates a "commented" status, // while the accept creates an "accepted" status. Since accept is // stronger, it should win and persist. $u_status = idx($u, 'status'); $v_status = idx($v, 'status'); $u_str = DifferentialReviewerStatus::getStatusStrength($u_status); $v_str = DifferentialReviewerStatus::getStatusStrength($v_status); if ($u_str > $v_str) { $result['status'] = $u_status; } else { $result['status'] = $v_status; } break; } return $result; } protected function applyFinalEffects( PhabricatorLiskDAO $object, array $xactions) { + foreach ($xactions as $xaction) { + switch ($xaction->getTransactionType()) { + case DifferentialTransaction::TYPE_UPDATE: + $diff = $this->loadDiff($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; + } + } + $status_accepted = ArcanistDifferentialRevisionStatus::ACCEPTED; $status_revision = ArcanistDifferentialRevisionStatus::NEEDS_REVISION; $status_review = ArcanistDifferentialRevisionStatus::NEEDS_REVIEW; $old_status = $object->getStatus(); switch ($old_status) { case $status_accepted: case $status_revision: case $status_review: // 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. $new_revision = id(new DifferentialRevisionQuery()) ->setViewer($this->getActor()) ->needReviewerStatus(true) ->withIDs(array($object->getID())) ->executeOne(); if (!$new_revision) { throw new Exception( pht('Failed to load revision from transaction finalization.')); } // 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; foreach ($new_revision->getReviewerStatus() as $reviewer) { $reviewer_status = $reviewer->getStatus(); switch ($reviewer_status) { case DifferentialReviewerStatus::STATUS_REJECTED: $has_rejecting_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()) { $has_accepting_user = true; } break; } } $new_status = null; if ($has_accepting_user && !$has_rejecting_reviewer && !$has_rejecting_older_reviewer && !$has_blocking_reviewer) { $new_status = $status_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 = $status_revision; } else if ($old_status == $status_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 = $status_review; } if ($new_status !== null && $new_status != $old_status) { $xaction = id(new DifferentialTransaction()) ->setTransactionType(DifferentialTransaction::TYPE_STATUS) ->setOldValue($old_status) ->setNewValue($new_status); $xaction = $this->populateTransaction($object, $xaction)->save(); $xactions[] = $xaction; $object->setStatus($new_status)->save(); } break; default: // Revisions can't transition out of other statuses (like closed or // abandoned) as a side effect of reviewer status changes. break; } return $xactions; } protected function validateTransaction( PhabricatorLiskDAO $object, $type, array $xactions) { $errors = parent::validateTransaction($object, $type, $xactions); foreach ($xactions as $xaction) { switch ($type) { case DifferentialTransaction::TYPE_UPDATE: $diff = $this->loadDiff($xaction->getNewValue()); if (!$diff) { $errors[] = new PhabricatorApplicationTransactionValidationError( $type, pht('Invalid'), pht('The specified diff does not exist.'), $xaction); } else if (($diff->getRevisionID()) && ($diff->getRevisionID() != $object->getID())) { $errors[] = new PhabricatorApplicationTransactionValidationError( $type, pht('Invalid'), pht( 'You can not update this revision to the specified diff, '. 'because the diff is already attached to another revision.'), $xaction); } break; case DifferentialTransaction::TYPE_ACTION: $error = $this->validateDifferentialAction( $object, $type, $xaction, $xaction->getNewValue()); if ($error) { $errors[] = new PhabricatorApplicationTransactionValidationError( $type, pht('Invalid'), $error, $xaction); } break; } } return $errors; } private function validateDifferentialAction( DifferentialRevision $revision, $type, DifferentialTransaction $xaction, $action) { $author_phid = $revision->getAuthorPHID(); $actor_phid = $this->getActor()->getPHID(); $actor_is_author = ($author_phid == $actor_phid); $config_close_key = 'differential.always-allow-close'; $always_allow_close = PhabricatorEnv::getEnvConfig($config_close_key); $config_reopen_key = 'differential.allow-reopen'; $allow_reopen = PhabricatorEnv::getEnvConfig($config_reopen_key); $config_self_accept_key = 'differential.allow-self-accept'; $allow_self_accept = PhabricatorEnv::getEnvConfig($config_self_accept_key); $revision_status = $revision->getStatus(); $status_accepted = ArcanistDifferentialRevisionStatus::ACCEPTED; $status_abandoned = ArcanistDifferentialRevisionStatus::ABANDONED; $status_closed = ArcanistDifferentialRevisionStatus::CLOSED; switch ($action) { case DifferentialAction::ACTION_ACCEPT: if ($actor_is_author && !$allow_self_accept) { return pht( 'You can not accept this revision because you are the owner.'); } if ($revision_status == $status_abandoned) { return pht( 'You can not accept this revision because it has been '. 'abandoned.'); } if ($revision_status == $status_closed) { return pht( 'You can not accept this revision because it has already been '. 'closed.'); } break; case DifferentialAction::ACTION_REJECT: if ($actor_is_author) { return pht( 'You can not request changes to your own revision.'); } if ($revision_status == $status_abandoned) { return pht( 'You can not request changes to this revision because it has been '. 'abandoned.'); } if ($revision_status == $status_closed) { return pht( 'You can not request changes to this revision because it has '. 'already been closed.'); } break; case DifferentialAction::ACTION_RESIGN: // You can always resign from a revision if you're a reviewer. If you // aren't, this is a no-op rather than invalid. break; case DifferentialAction::ACTION_CLAIM: // You can claim a revision if you're not the owner. If you are, this // is a no-op rather than invalid. if ($revision_status == $status_closed) { return pht( "You can not commandeer this revision because it has already been ". "closed."); } break; case DifferentialAction::ACTION_ABANDON: if (!$actor_is_author) { return pht( "You can not abandon this revision because you do not own it. ". "You can only abandon revisions you own."); } if ($revision_status == $status_closed) { return pht( "You can not abandon this revision because it has already been ". "closed."); } // NOTE: Abandons of already-abandoned revisions are treated as no-op // instead of invalid. Other abandons are OK. break; case DifferentialAction::ACTION_RECLAIM: if (!$actor_is_author) { return pht( "You can not reclaim this revision because you do not own ". "it. You can only reclaim revisions you own."); } if ($revision_status == $status_closed) { return pht( "You can not reclaim this revision because it has already been ". "closed."); } // NOTE: Reclaims of other non-abandoned revisions are treated as no-op // instead of invalid. break; case DifferentialAction::ACTION_REOPEN: if (!$allow_reopen) { return pht( 'The reopen action is not enabled on this Phabricator install. '. 'Adjust your configuration to enable it.'); } // NOTE: If the revision is not closed, this is caught as a no-op // instead of an invalid transaction. break; case DifferentialAction::ACTION_RETHINK: if (!$actor_is_author) { return pht( "You can not plan changes to this revision because you do not ". "own it. To plan changes to a revision, you must be its owner."); } switch ($revision_status) { case ArcanistDifferentialRevisionStatus::ACCEPTED: case ArcanistDifferentialRevisionStatus::NEEDS_REVISION: case ArcanistDifferentialRevisionStatus::NEEDS_REVIEW: // These are OK. break; case ArcanistDifferentialRevisionStatus::CHANGES_PLANNED: // Let this through, it's a no-op. break; case ArcanistDifferentialRevisionStatus::ABANDONED: return pht( "You can not plan changes to this revision because it has ". "been abandoned."); case ArcanistDifferentialRevisionStatus::CLOSED: return pht( "You can not plan changes to this revision because it has ". "already been closed."); default: throw new Exception( pht( 'Encountered unexpected revision status ("%s") when '. 'validating "%s" action.', $revision_status, $action)); } break; case DifferentialAction::ACTION_REQUEST: if (!$actor_is_author) { return pht( "You can not request review of this revision because you do ". "not own it. To request review of a revision, you must be its ". "owner."); } switch ($revision_status) { case ArcanistDifferentialRevisionStatus::ACCEPTED: case ArcanistDifferentialRevisionStatus::NEEDS_REVISION: case ArcanistDifferentialRevisionStatus::CHANGES_PLANNED: // These are OK. break; case ArcanistDifferentialRevisionStatus::NEEDS_REVIEW: // This will be caught as "no effect" later on. break; case ArcanistDifferentialRevisionStatus::ABANDONED: return pht( "You can not request review of this revision because it has ". "been abandoned. Instead, reclaim it."); case ArcanistDifferentialRevisionStatus::CLOSED: return pht( "You can not request review of this revision because it has ". "already been closed."); default: throw new Exception( pht( 'Encountered unexpected revision status ("%s") when '. 'validating "%s" action.', $revision_status, $action)); } break; case DifferentialAction::ACTION_CLOSE: // We force revisions closed when we discover a corresponding commit. // In this case, revisions are allowed to transition to closed from // any state. This is an automated action taken by the daemons. if (!$this->getIsCloseByCommit()) { if (!$actor_is_author && !$always_allow_close) { return pht( "You can not close this revision because you do not own it. To ". "close a revision, you must be its owner."); } if ($revision_status != $status_accepted) { return pht( "You can not close this revision because it has not been ". "accepted. You can only close accepted revisions."); } } break; } return null; } 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 requireCapabilities( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { } return parent::requireCapabilities($object, $xaction); } protected function shouldPublishFeedStory( PhabricatorLiskDAO $object, array $xactions) { return true; } protected function shouldSendMail( PhabricatorLiskDAO $object, array $xactions) { return true; } protected function getMailTo(PhabricatorLiskDAO $object) { $phids = array(); $phids[] = $object->getAuthorPHID(); foreach ($object->getReviewerStatus() as $reviewer) { $phids[] = $reviewer->getReviewerPHID(); } return $phids; } protected function getMailCC(PhabricatorLiskDAO $object) { $phids = parent::getMailCC($object); if ($this->heraldEmailPHIDs) { foreach ($this->heraldEmailPHIDs as $phid) { $phids[] = $phid; } } return $phids; } protected function getMailAction( PhabricatorLiskDAO $object, array $xactions) { $action = parent::getMailAction($object, $xactions); $strongest = $this->getStrongestAction($object, $xactions); switch ($strongest->getTransactionType()) { case DifferentialTransaction::TYPE_UPDATE: $count = new PhutilNumber($object->getLineCount()); $action = pht('%s, %d line(s)', $action, $count); break; } return $action; } protected function getMailSubjectPrefix() { return PhabricatorEnv::getEnvConfig('metamta.differential.subject-prefix'); } 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) { $id = $object->getID(); $title = $object->getTitle(); $original_title = $object->getOriginalTitle(); $subject = "D{$id}: {$title}"; $thread_topic = "D{$id}: {$original_title}"; return id(new PhabricatorMetaMTAMail()) ->setSubject($subject) ->addHeader('Thread-Topic', $thread_topic); } protected function buildMailBody( PhabricatorLiskDAO $object, array $xactions) { $body = parent::buildMailBody($object, $xactions); $type_inline = DifferentialTransaction::TYPE_INLINE; $inlines = array(); foreach ($xactions as $xaction) { if ($xaction->getTransactionType() == $type_inline) { $inlines[] = $xaction; } } $changed_uri = $this->getChangedPriorToCommitURI(); if ($changed_uri) { $body->addTextSection( pht('CHANGED PRIOR TO COMMIT'), $changed_uri); } if ($inlines) { $body->addTextSection( pht('INLINE COMMENTS'), $this->renderInlineCommentsForMail($object, $inlines)); } $body->addTextSection( pht('REVISION DETAIL'), PhabricatorEnv::getProductionURI('/D'.$object->getID())); return $body; } protected function supportsSearch() { return true; } protected function extractFilePHIDsFromCustomTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { switch ($xaction->getTransactionType()) { } return parent::extractFilePHIDsFromCustomTransaction($object, $xaction); } protected function expandCustomRemarkupBlockTransactions( PhabricatorLiskDAO $object, array $xactions, $blocks, PhutilMarkupEngine $engine) { $flat_blocks = array_mergev($blocks); $huge_block = implode("\n\n", $flat_blocks); $task_map = array(); $task_refs = id(new ManiphestCustomFieldStatusParser()) ->parseCorpus($huge_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($huge_block); foreach ($rev_refs as $match) { foreach ($match['monograms'] as $monogram) { $rev_id = (int)trim($monogram, 'dD'); $rev_map[$rev_id] = true; } } $edges = array(); if ($task_map) { $tasks = id(new ManiphestTaskQuery()) ->setViewer($this->getActor()) ->withIDs(array_keys($task_map)) ->execute(); if ($tasks) { $edge_related = PhabricatorEdgeConfig::TYPE_DREV_HAS_RELATED_TASK; $edges[$edge_related] = mpull($tasks, 'getPHID', 'getPHID'); } } 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) { $edge_depends = PhabricatorEdgeConfig::TYPE_DREV_DEPENDS_ON_DREV; $edges[$edge_depends] = $rev_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 renderInlineCommentsForMail( PhabricatorLiskDAO $object, array $inlines) { $context_key = 'metamta.differential.unified-comment-context'; $show_context = PhabricatorEnv::getEnvConfig($context_key); $changeset_ids = array(); foreach ($inlines as $inline) { $id = $inline->getComment()->getChangesetID(); $changeset_ids[$id] = $id; } // TODO: We should write a proper Query class for this eventually. $changesets = id(new DifferentialChangeset())->loadAllWhere( 'id IN (%Ld)', $changeset_ids); if ($show_context) { $hunk_parser = new DifferentialHunkParser(); foreach ($changesets as $changeset) { $changeset->attachHunks($changeset->loadHunks()); } } $inline_groups = DifferentialTransactionComment::sortAndGroupInlines( $inlines, $changesets); $result = array(); foreach ($inline_groups as $changeset_id => $group) { $changeset = idx($changesets, $changeset_id); if (!$changeset) { continue; } foreach ($group as $inline) { $comment = $inline->getComment(); $file = $changeset->getFilename(); $start = $comment->getLineNumber(); $len = $comment->getLineLength(); if ($len) { $range = $start.'-'.($start + $len); } else { $range = $start; } $inline_content = $comment->getContent(); if (!$show_context) { $result[] = "{$file}:{$range} {$inline_content}"; } else { $result[] = "================"; $result[] = "Comment at: " . $file . ":" . $range; $result[] = $hunk_parser->makeContextDiff( $changeset->getHunks(), $comment->getIsNewFile(), $comment->getLineNumber(), $comment->getLineLength(), 1); $result[] = "----------------"; $result[] = $inline_content; $result[] = null; } } } return implode("\n", $result); } - private function loadDiff($phid) { - return id(new DifferentialDiffQuery()) + private function loadDiff($phid, $need_changesets = false) { + $query = id(new DifferentialDiffQuery()) ->withPHIDs(array($phid)) - ->setViewer($this->getActor()) - ->executeOne(); + ->setViewer($this->getActor()); + + if ($need_changesets) { + $query->needChangesets(true); + } + + return $query->executeOne(); } /* -( Herald Integration )------------------------------------------------- */ protected function shouldApplyHeraldRules( PhabricatorLiskDAO $object, array $xactions) { if ($this->getIsNewObject()) { return true; } foreach ($xactions as $xaction) { switch ($xaction->getTransactionType()) { case DifferentialTransaction::TYPE_UPDATE: if (!$this->getIsCloseByCommit()) { return true; } } } return parent::shouldApplyHeraldRules($object, $xactions); } protected function buildHeraldAdapter( PhabricatorLiskDAO $object, array $xactions) { $unsubscribed_phids = PhabricatorEdgeQuery::loadDestinationPHIDs( $object->getPHID(), PhabricatorEdgeConfig::TYPE_OBJECT_HAS_UNSUBSCRIBER); $subscribed_phids = PhabricatorSubscribersQuery::loadSubscribersForPHID( $object->getPHID()); $revision = id(new DifferentialRevisionQuery()) ->setViewer($this->getActor()) ->withPHIDs(array($object->getPHID())) ->needActiveDiffs(true) ->needReviewerStatus(true) ->executeOne(); if (!$revision) { throw new Exception( pht( 'Failed to load revision for Herald adapter construction!')); } $adapter = HeraldDifferentialRevisionAdapter::newLegacyAdapter( $revision, $revision->getActiveDiff()); $reviewers = $revision->getReviewerStatus(); $reviewer_phids = mpull($reviewers, 'getReviewerPHID'); $adapter->setExplicitCCs($subscribed_phids); $adapter->setExplicitReviewers($reviewer_phids); $adapter->setForbiddenCCs($unsubscribed_phids); $adapter->setIsNewObject($this->getIsNewObject()); return $adapter; } protected function didApplyHeraldRules( PhabricatorLiskDAO $object, HeraldAdapter $adapter, HeraldTranscript $transcript) { $xactions = array(); // Build a transaction to adjust CCs. $ccs = array( '+' => array_keys($adapter->getCCsAddedByHerald()), '-' => array_keys($adapter->getCCsRemovedByHerald()), ); $value = array(); foreach ($ccs as $type => $phids) { foreach ($phids as $phid) { $value[$type][$phid] = $phid; } } if ($value) { $xactions[] = id(new DifferentialTransaction()) ->setTransactionType(PhabricatorTransactions::TYPE_SUBSCRIBERS) ->setNewValue($value); } // Build a transaction to adjust reviewers. $reviewers = array( DifferentialReviewerStatus::STATUS_ADDED => array_keys($adapter->getReviewersAddedByHerald()), DifferentialReviewerStatus::STATUS_BLOCKING => array_keys($adapter->getBlockingReviewersAddedByHerald()), ); $value = array(); foreach ($reviewers as $status => $phids) { foreach ($phids as $phid) { $value['+'][$phid] = array( 'data' => array( 'status' => $status, ), ); } } if ($value) { $edge_reviewer = PhabricatorEdgeConfig::TYPE_DREV_HAS_REVIEWER; $xactions[] = id(new DifferentialTransaction()) ->setTransactionType(PhabricatorTransactions::TYPE_EDGE) ->setMetadataValue('edge:type', $edge_reviewer) ->setNewValue($value); } // Save extra email PHIDs for later. $this->heraldEmailPHIDs = $adapter->getEmailPHIDsAddedByHerald(); // Apply build plans. HarbormasterBuildable::applyBuildPlans( $adapter->getDiff(), $adapter->getPHID(), $adapter->getBuildPlans()); return $xactions; } + /** + * 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) { + + $changesets = $diff->getChangesets(); + + // TODO: This all needs to be modernized. + + $project = $diff->loadArcanistProject(); + if (!$project) { + // Probably an old revision from before projects. + return; + } + + $repository = $project->loadRepository(); + if (!$repository) { + // Probably no project <-> repository link, or the repository where the + // project 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)); + } + } + + $paths = array(); + foreach ($changesets as $changeset) { + $paths[] = $path_prefix.'/'.$changeset->getFilename(); + } + + // 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 %Q', + $table->getTableName(), + implode(', ', $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 %Q', + ArcanistDifferentialRevisionHash::TABLE_NAME, + implode(', ', $sql)); + } + } + }