diff --git a/src/applications/differential/editor/comment/DifferentialCommentEditor.php b/src/applications/differential/editor/comment/DifferentialCommentEditor.php index 0ea5f93255..f125f78d27 100644 --- a/src/applications/differential/editor/comment/DifferentialCommentEditor.php +++ b/src/applications/differential/editor/comment/DifferentialCommentEditor.php @@ -1,583 +1,608 @@ revision = $revision; $this->actorPHID = $actor_phid; $this->action = $action; } public function setParentMessageID($parent_message_id) { $this->parentMessageID = $parent_message_id; return $this; } public function setMessage($message) { $this->message = $message; return $this; } public function setAttachInlineComments($attach) { $this->attachInlineComments = $attach; return $this; } public function setChangedByCommit($changed_by_commit) { $this->changedByCommit = $changed_by_commit; return $this; } public function getChangedByCommit() { return $this->changedByCommit; } public function setAddedReviewers($added_reviewers) { $this->addedReviewers = $added_reviewers; return $this; } public function getAddedReviewers() { return $this->addedReviewers; } public function setAddedCCs($added_ccs) { $this->addedCCs = $added_ccs; return $this; } public function getAddedCCs() { return $this->addedCCs; } public function setContentSource(PhabricatorContentSource $content_source) { $this->contentSource = $content_source; return $this; } + public function setIsDaemonWorkflow($is_daemon) { + $this->isDaemonWorkflow = $is_daemon; + return $this; + } + public function save() { $revision = $this->revision; $action = $this->action; $actor_phid = $this->actorPHID; $actor = id(new PhabricatorUser())->loadOneWhere('PHID = %s', $actor_phid); $actor_is_author = ($actor_phid == $revision->getAuthorPHID()); $actor_is_admin = $actor->getIsAdmin(); $revision_status = $revision->getStatus(); $revision->loadRelationships(); $reviewer_phids = $revision->getReviewers(); if ($reviewer_phids) { $reviewer_phids = array_combine($reviewer_phids, $reviewer_phids); } $metadata = array(); $inline_comments = array(); if ($this->attachInlineComments) { $inline_comments = id(new DifferentialInlineComment())->loadAllWhere( 'authorPHID = %s AND revisionID = %d AND commentID IS NULL', $this->actorPHID, $revision->getID()); } switch ($action) { case DifferentialAction::ACTION_COMMENT: if (!$this->message && !$inline_comments) { throw new DifferentialActionHasNoEffectException( "You are submitting an empty comment with no action: ". "you must act on the revision or post a comment."); } break; case DifferentialAction::ACTION_RESIGN: if ($actor_is_author) { throw new Exception('You can not resign from your own revision!'); } if (empty($reviewer_phids[$actor_phid])) { throw new DifferentialActionHasNoEffectException( "You can not resign from this revision because you are not ". "a reviewer."); } DifferentialRevisionEditor::alterReviewers( $revision, $reviewer_phids, $rem = array($actor_phid), $add = array(), $actor_phid); break; case DifferentialAction::ACTION_ABANDON: if (!($actor_is_author || $actor_is_admin)) { throw new Exception('You can only abandon your own revisions.'); } if ($revision_status == ArcanistDifferentialRevisionStatus::COMMITTED) { throw new DifferentialActionHasNoEffectException( "You can not abandon this revision because it has already ". "been committed."); } if ($revision_status == ArcanistDifferentialRevisionStatus::ABANDONED) { throw new DifferentialActionHasNoEffectException( "You can not abandon this revision because it has already ". "been abandoned."); } $revision->setStatus(ArcanistDifferentialRevisionStatus::ABANDONED); break; case DifferentialAction::ACTION_ACCEPT: if ($actor_is_author) { throw new Exception('You can not accept your own revision.'); } if (($revision_status != ArcanistDifferentialRevisionStatus::NEEDS_REVIEW) && ($revision_status != ArcanistDifferentialRevisionStatus::NEEDS_REVISION)) { switch ($revision_status) { case ArcanistDifferentialRevisionStatus::ACCEPTED: throw new DifferentialActionHasNoEffectException( "You can not accept this revision because someone else ". "already accepted it."); case ArcanistDifferentialRevisionStatus::ABANDONED: throw new DifferentialActionHasNoEffectException( "You can not accept this revision because it has been ". "abandoned."); case ArcanistDifferentialRevisionStatus::COMMITTED: throw new DifferentialActionHasNoEffectException( "You can not accept this revision because it has already ". "been committed."); default: throw new Exception( "Unexpected revision state '{$revision_status}'!"); } } $revision ->setStatus(ArcanistDifferentialRevisionStatus::ACCEPTED); if (!isset($reviewer_phids[$actor_phid])) { DifferentialRevisionEditor::alterReviewers( $revision, $reviewer_phids, $rem = array(), $add = array($actor_phid), $actor_phid); } break; case DifferentialAction::ACTION_REQUEST: if (!$actor_is_author) { throw new Exception('You must own a revision to request review.'); } switch ($revision_status) { case ArcanistDifferentialRevisionStatus::ACCEPTED: case ArcanistDifferentialRevisionStatus::NEEDS_REVISION: $revision->setStatus( ArcanistDifferentialRevisionStatus::NEEDS_REVIEW); break; case ArcanistDifferentialRevisionStatus::NEEDS_REVIEW: throw new DifferentialActionHasNoEffectException( "You can not request review of this revision because it has ". "been abandoned."); case ArcanistDifferentialRevisionStatus::ABANDONED: throw new DifferentialActionHasNoEffectException( "You can not request review of this revision because it has ". "been abandoned."); case ArcanistDifferentialRevisionStatus::COMMITTED: throw new DifferentialActionHasNoEffectException( "You can not request review of this revision because it has ". "already been committed."); default: throw new Exception( "Unexpected revision state '{$revision_status}'!"); } $added_reviewers = $this->addReviewers(); if ($added_reviewers) { $key = DifferentialComment::METADATA_ADDED_REVIEWERS; $metadata[$key] = $added_reviewers; } break; case DifferentialAction::ACTION_REJECT: if ($actor_is_author) { throw new Exception( 'You can not request changes to your own revision.'); } switch ($revision_status) { case ArcanistDifferentialRevisionStatus::ACCEPTED: case ArcanistDifferentialRevisionStatus::NEEDS_REVISION: case ArcanistDifferentialRevisionStatus::NEEDS_REVIEW: // NOTE: We allow you to reject an already-rejected revision // because it doesn't create any ambiguity and avoids a rather // needless dialog. break; case ArcanistDifferentialRevisionStatus::ABANDONED: throw new DifferentialActionHasNoEffectException( "You can not request changes to this revision because it has ". "been abandoned."); case ArcanistDifferentialRevisionStatus::COMMITTED: throw new DifferentialActionHasNoEffectException( "You can not request changes to this revision because it has ". "already been committed."); default: throw new Exception( "Unexpected revision state '{$revision_status}'!"); } if (!isset($reviewer_phids[$actor_phid])) { DifferentialRevisionEditor::alterReviewers( $revision, $reviewer_phids, $rem = array(), $add = array($actor_phid), $actor_phid); } $revision ->setStatus(ArcanistDifferentialRevisionStatus::NEEDS_REVISION); break; case DifferentialAction::ACTION_RETHINK: if (!$actor_is_author) { throw new Exception( "You can not plan changes to somebody else's revision"); } switch ($revision_status) { case ArcanistDifferentialRevisionStatus::ACCEPTED: case ArcanistDifferentialRevisionStatus::NEEDS_REVISION: case ArcanistDifferentialRevisionStatus::NEEDS_REVIEW: break; case ArcanistDifferentialRevisionStatus::ABANDONED: throw new DifferentialActionHasNoEffectException( "You can not plan changes to this revision because it has ". "been abandoned."); case ArcanistDifferentialRevisionStatus::COMMITTED: throw new DifferentialActionHasNoEffectException( "You can not plan changes to this revision because it has ". "already been committed."); default: throw new Exception( "Unexpected revision state '{$revision_status}'!"); } $revision ->setStatus(ArcanistDifferentialRevisionStatus::NEEDS_REVISION); break; case DifferentialAction::ACTION_RECLAIM: if (!$actor_is_author) { throw new Exception('You can not reclaim a revision you do not own.'); } if ($revision_status != ArcanistDifferentialRevisionStatus::ABANDONED) { throw new DifferentialActionHasNoEffectException( "You can not reclaim this revision because it is not abandoned."); } $revision ->setStatus(ArcanistDifferentialRevisionStatus::NEEDS_REVIEW); break; case DifferentialAction::ACTION_COMMIT: - // TODO: We allow this from any state because the daemons are - // considered authoritative. However, this technically means that anyone - // can mark anything committed at any time with the right POST. - // Ideally we should set a flag on the editor to tell it whether it - // should enforce the web workflow rules (actor must be author and - // state must be 'accepted') or the daemon workflow rules (no rules). + // NOTE: The daemons can mark things committed from any state. We treat + // them as completely authoritative. + + if (!$this->isDaemonWorkflow) { + if (!$actor_is_author) { + throw new Exception( + "You can not mark a revision you don't own as committed."); + } + + $status_committed = ArcanistDifferentialRevisionStatus::COMMITTED; + $status_accepted = ArcanistDifferentialRevisionStatus::ACCEPTED; + + if ($revision_status == $status_committed) { + throw new DifferentialActionHasNoEffectException( + "You can not mark this revision as committed because it has ". + "already been marked as committed."); + } + + if ($revision_status != $status_accepted) { + throw new DifferentialActionHasNoEffectException( + "You can not mark this revision as committed because it is ". + "has not been accepted."); + } + } $revision ->setStatus(ArcanistDifferentialRevisionStatus::COMMITTED); break; case DifferentialAction::ACTION_ADDREVIEWERS: $added_reviewers = $this->addReviewers(); if ($added_reviewers) { $key = DifferentialComment::METADATA_ADDED_REVIEWERS; $metadata[$key] = $added_reviewers; } else { $user_tried_to_add = count($this->getAddedReviewers()); if ($user_tried_to_add == 0) { throw new DifferentialActionHasNoEffectException( "You can not add reviewers, because you did not specify any ". "reviewers."); } else if ($user_tried_to_add == 1) { throw new DifferentialActionHasNoEffectException( "You can not add that reviewer, because they are already an ". "author or reviewer."); } else { throw new DifferentialActionHasNoEffectException( "You can not add those reviewers, because they are all already ". "authors or reviewers."); } } break; case DifferentialAction::ACTION_ADDCCS: $added_ccs = $this->getAddedCCs(); $user_tried_to_add = count($added_ccs); $added_ccs = $this->filterAddedCCs($added_ccs); if ($added_ccs) { foreach ($added_ccs as $cc) { DifferentialRevisionEditor::addCC( $revision, $cc, $this->actorPHID); } $key = DifferentialComment::METADATA_ADDED_CCS; $metadata[$key] = $added_ccs; } else { if ($user_tried_to_add == 0) { throw new DifferentialActionHasNoEffectException( "You can not add CCs, because you did not specify any ". "CCs."); } else if ($user_tried_to_add == 1) { throw new DifferentialActionHasNoEffectException( "You can not add that CC, because they are already an ". "author, reviewer or CC."); } else { throw new DifferentialActionHasNoEffectException( "You can not add those CCs, because they are all already ". "authors, reviewers or CCs."); } } break; default: throw new Exception('Unsupported action.'); } // Update information about reviewer in charge. if ($action == DifferentialAction::ACTION_ACCEPT || $action == DifferentialAction::ACTION_REJECT) { $revision->setLastReviewerPHID($actor_phid); } // Always save the revision (even if we didn't actually change any of its // properties) so that it jumps to the top of the revision list when sorted // by "updated". Notably, this allows "ping" comments to push it to the // top of the action list. $revision->save(); if ($action != DifferentialAction::ACTION_RESIGN && $this->actorPHID != $revision->getAuthorPHID() && !in_array($this->actorPHID, $revision->getReviewers())) { DifferentialRevisionEditor::addCC( $revision, $this->actorPHID, $this->actorPHID); } $comment = id(new DifferentialComment()) ->setAuthorPHID($this->actorPHID) ->setRevisionID($revision->getID()) ->setAction($action) ->setContent((string)$this->message) ->setMetadata($metadata); if ($this->contentSource) { $comment->setContentSource($this->contentSource); } $comment->save(); $changesets = array(); if ($inline_comments) { $load_ids = mpull($inline_comments, 'getChangesetID'); if ($load_ids) { $load_ids = array_unique($load_ids); $changesets = id(new DifferentialChangeset())->loadAllWhere( 'id in (%Ld)', $load_ids); } foreach ($inline_comments as $inline) { $inline->setCommentID($comment->getID()); $inline->save(); } } // Find any "@mentions" in the comment blocks. $content_blocks = array($comment->getContent()); foreach ($inline_comments as $inline) { $content_blocks[] = $inline->getContent(); } $mention_ccs = PhabricatorMarkupEngine::extractPHIDsFromMentions( $content_blocks); if ($mention_ccs) { $mention_ccs = $this->filterAddedCCs($mention_ccs); if ($mention_ccs) { $metadata = $comment->getMetadata(); $metacc = idx( $metadata, DifferentialComment::METADATA_ADDED_CCS, array()); foreach ($mention_ccs as $cc_phid) { DifferentialRevisionEditor::addCC( $revision, $cc_phid, $this->actorPHID); $metacc[] = $cc_phid; } $metadata[DifferentialComment::METADATA_ADDED_CCS] = $metacc; $comment->setMetadata($metadata); $comment->save(); } } $phids = array($this->actorPHID); $handles = id(new PhabricatorObjectHandleData($phids)) ->loadHandles(); $actor_handle = $handles[$this->actorPHID]; $xherald_header = HeraldTranscript::loadXHeraldRulesHeader( $revision->getPHID()); id(new DifferentialCommentMail( $revision, $actor_handle, $comment, $changesets, $inline_comments)) ->setToPHIDs( array_merge( $revision->getReviewers(), array($revision->getAuthorPHID()))) ->setCCPHIDs($revision->getCCPHIDs()) ->setChangedByCommit($this->getChangedByCommit()) ->setXHeraldRulesHeader($xherald_header) ->setParentMessageID($this->parentMessageID) ->send(); $event_data = array( 'revision_id' => $revision->getID(), 'revision_phid' => $revision->getPHID(), 'revision_name' => $revision->getTitle(), 'revision_author_phid' => $revision->getAuthorPHID(), 'action' => $comment->getAction(), 'feedback_content' => $comment->getContent(), 'actor_phid' => $this->actorPHID, ); id(new PhabricatorTimelineEvent('difx', $event_data)) ->recordEvent(); // TODO: Move to a daemon? id(new PhabricatorFeedStoryPublisher()) ->setStoryType(PhabricatorFeedStoryTypeConstants::STORY_DIFFERENTIAL) ->setStoryData($event_data) ->setStoryTime(time()) ->setStoryAuthorPHID($this->actorPHID) ->setRelatedPHIDs( array( $revision->getPHID(), $this->actorPHID, $revision->getAuthorPHID(), )) ->publish(); // TODO: Move to a daemon? PhabricatorSearchDifferentialIndexer::indexRevision($revision); return $comment; } private function filterAddedCCs(array $ccs) { $revision = $this->revision; $current_ccs = $revision->getCCPHIDs(); $current_ccs = array_fill_keys($current_ccs, true); $reviewer_phids = $revision->getReviewers(); $reviewer_phids = array_fill_keys($reviewer_phids, true); foreach ($ccs as $key => $cc) { if (isset($current_ccs[$cc])) { unset($ccs[$key]); } if (isset($reviewer_phids[$cc])) { unset($ccs[$key]); } if ($cc == $revision->getAuthorPHID()) { unset($ccs[$key]); } } return $ccs; } private function addReviewers() { $revision = $this->revision; $added_reviewers = $this->getAddedReviewers(); $reviewer_phids = $revision->getReviewers(); foreach ($added_reviewers as $k => $user_phid) { if ($user_phid == $revision->getAuthorPHID()) { unset($added_reviewers[$k]); } if (!empty($reviewer_phids[$user_phid])) { unset($added_reviewers[$k]); } } $added_reviewers = array_unique($added_reviewers); if ($added_reviewers) { DifferentialRevisionEditor::alterReviewers( $revision, $reviewer_phids, $rem = array(), $added_reviewers, $this->actorPHID); } return $added_reviewers; } } diff --git a/src/applications/differential/mail/reviewrequest/DifferentialReviewRequestMail.php b/src/applications/differential/mail/reviewrequest/DifferentialReviewRequestMail.php index 4679acd59f..caff1f1dd3 100644 --- a/src/applications/differential/mail/reviewrequest/DifferentialReviewRequestMail.php +++ b/src/applications/differential/mail/reviewrequest/DifferentialReviewRequestMail.php @@ -1,146 +1,147 @@ comments = $comments; return $this; } public function getComments() { return $this->comments; } public function __construct( DifferentialRevision $revision, PhabricatorObjectHandle $actor, array $changesets) { $this->setRevision($revision); $this->setActorHandle($actor); $this->setChangesets($changesets); } protected function renderReviewersLine() { $reviewers = $this->getRevision()->getReviewers(); $handles = id(new PhabricatorObjectHandleData($reviewers))->loadHandles(); return 'Reviewers: '.$this->renderHandleList($handles, $reviewers); } protected function renderReviewRequestBody() { $revision = $this->getRevision(); $body = array(); if ($this->isFirstMailToRecipients()) { if ($revision->getSummary() != '') { $body[] = $this->formatText($revision->getSummary()); $body[] = null; } $body[] = 'TEST PLAN'; $body[] = $this->formatText($revision->getTestPlan()); $body[] = null; } else { if (strlen($this->getComments())) { $body[] = $this->formatText($this->getComments()); $body[] = null; } } $body[] = $this->renderRevisionDetailLink(); $body[] = null; $changesets = $this->getChangesets(); if ($changesets) { $body[] = 'AFFECTED FILES'; foreach ($changesets as $changeset) { $body[] = ' '.$changeset->getFilename(); } $body[] = null; } $inline_key = 'metamta.differential.inline-patches'; $inline_max_length = PhabricatorEnv::getEnvConfig($inline_key); if ($inline_max_length) { $patch = $this->buildPatch(); if (count(explode("\n", $patch)) <= $inline_max_length) { $body[] = 'CHANGE DETAILS'; $body[] = $patch; } } return implode("\n", $body); } protected function buildAttachments() { $attachments = array(); if (PhabricatorEnv::getEnvConfig('metamta.differential.attach-patches')) { - $revision_id = $this->getRevision()->getID(); + $revision = $this->getRevision(); + $revision_id = $revision->getID(); $diffs = $revision->loadDiffs(); $diff_number = count($diffs); $attachments[] = new PhabricatorMetaMTAAttachment( $this->buildPatch(), "D{$revision_id}.{$diff_number}.patch", 'text/x-patch; charset=utf-8' ); } return $attachments; } private function buildPatch() { $revision = $this->getRevision(); $revision_id = $revision->getID(); $diffs = $revision->loadDiffs(); $diff_number = count($diffs); $diff = array_pop($diffs); $diff->attachChangesets($diff->loadChangesets()); // TODO: We could batch this to improve performance. foreach ($diff->getChangesets() as $changeset) { $changeset->attachHunks($changeset->loadHunks()); } $diff_dict = $diff->getDiffDict(); $changes = array(); foreach ($diff_dict['changes'] as $changedict) { $changes[] = ArcanistDiffChange::newFromDictionary($changedict); } $bundle = ArcanistBundle::newFromChanges($changes); $format = PhabricatorEnv::getEnvConfig('metamta.differential.patch-format'); switch ($format) { case 'git': return $bundle->toGitPatch(); break; case 'unified': default: return $bundle->toUnifiedDiff(); break; } } } diff --git a/src/applications/repository/worker/commitmessageparser/base/PhabricatorRepositoryCommitMessageParserWorker.php b/src/applications/repository/worker/commitmessageparser/base/PhabricatorRepositoryCommitMessageParserWorker.php index a5f522c2c1..ec1b7fa2ce 100644 --- a/src/applications/repository/worker/commitmessageparser/base/PhabricatorRepositoryCommitMessageParserWorker.php +++ b/src/applications/repository/worker/commitmessageparser/base/PhabricatorRepositoryCommitMessageParserWorker.php @@ -1,170 +1,171 @@ commit; $data = id(new PhabricatorRepositoryCommitData())->loadOneWhere( 'commitID = %d', $commit->getID()); if (!$data) { $data = new PhabricatorRepositoryCommitData(); } $data->setCommitID($commit->getID()); $data->setAuthorName($author); $data->setCommitMessage($message); $repository = $this->repository; $detail_parser = $repository->getDetail( 'detail-parser', 'PhabricatorRepositoryDefaultCommitMessageDetailParser'); if ($detail_parser) { PhutilSymbolLoader::loadClass($detail_parser); $parser_obj = newv($detail_parser, array($commit, $data)); $parser_obj->parseCommitDetails(); } $author_phid = $data->getCommitDetail('authorPHID'); if ($author_phid) { $commit->setAuthorPHID($author_phid); $commit->save(); } $data->save(); $conn_w = id(new DifferentialRevision())->establishConnection('w'); // NOTE: The `differential_commit` table has a unique ID on `commitPHID`, // preventing more than one revision from being associated with a commit. // Generally this is good and desirable, but with the advent of hash // tracking we may end up in a situation where we match several different // revisions. We just kind of ignore this and pick one, we might want to // revisit this and do something differently. (If we match several revisions // someone probably did something very silly, though.) $revision_id = $data->getCommitDetail('differential.revisionID'); if (!$revision_id) { $hashes = $this->getCommitHashes( $this->repository, $this->commit); if ($hashes) { $query = new DifferentialRevisionQuery(); $query->withCommitHashes($hashes); $revisions = $query->execute(); if (!empty($revisions)) { $revision = $this->identifyBestRevision($revisions); $revision_id = $revision->getID(); } } } if ($revision_id) { $revision = id(new DifferentialRevision())->load($revision_id); if ($revision) { queryfx( $conn_w, 'INSERT IGNORE INTO %T (revisionID, commitPHID) VALUES (%d, %s)', DifferentialRevision::TABLE_COMMIT, $revision->getID(), $commit->getPHID()); if ($revision->getStatus() != ArcanistDifferentialRevisionStatus::COMMITTED) { $message = null; $committer = $data->getCommitDetail('authorPHID'); if (!$committer) { $committer = $revision->getAuthorPHID(); $message = 'Change committed by '.$data->getAuthorName().'.'; } $editor = new DifferentialCommentEditor( $revision, $committer, DifferentialAction::ACTION_COMMIT); + $editor->setIsDaemonWorkflow(true); $editor->setMessage($message)->save(); } } } } /** * When querying for revisions by hash, more than one revision may be found. * This function identifies the "best" revision from such a set. Typically, * there is only one revision found. Otherwise, we try to pick an accepted * revision first, followed by an open revision, and otherwise we go with a * committed or abandoned revision as a last resort. */ private function identifyBestRevision(array $revisions) { // get the simplest, common case out of the way if (count($revisions) == 1) { return reset($revisions); } $first_choice = array(); $second_choice = array(); $third_choice = array(); foreach ($revisions as $revision) { switch ($revision->getStatus()) { // "Accepted" revisions -- ostensibly what we're looking for! case ArcanistDifferentialRevisionStatus::ACCEPTED: $first_choice[] = $revision; break; // "Open" revisions case ArcanistDifferentialRevisionStatus::NEEDS_REVIEW: case ArcanistDifferentialRevisionStatus::NEEDS_REVISION: $second_choice[] = $revision; break; // default is a wtf? here default: case ArcanistDifferentialRevisionStatus::ABANDONED: case ArcanistDifferentialRevisionStatus::COMMITTED: $third_choice[] = $revision; break; } } // go down the ladder like a bro at last call if (!empty($first_choice)) { return $this->identifyMostRecentRevision($first_choice); } if (!empty($second_choice)) { return $this->identifyMostRecentRevision($second_choice); } if (!empty($third_choice)) { return $this->identifyMostRecentRevision($third_choice); } } /** * Given a set of revisions, returns the revision with the latest * updated time. This is ostensibly the most recent revision. */ private function identifyMostRecentRevision(array $revisions) { $revisions = msort($revisions, 'getDateModified'); return end($revisions); } }