diff --git a/src/applications/differential/field/specification/DifferentialFieldSpecification.php b/src/applications/differential/field/specification/DifferentialFieldSpecification.php index 77c108a585..8f99c85f96 100644 --- a/src/applications/differential/field/specification/DifferentialFieldSpecification.php +++ b/src/applications/differential/field/specification/DifferentialFieldSpecification.php @@ -1,922 +1,903 @@ value = $request->getStr('my-custom-field'); * * If you have some particularly complicated field, you may need to read * more data; this is why you have access to the entire request. * * You must implement this if you implement @{method:shouldAppearOnEdit}. * * You should not perform field validation here; instead, you should implement * @{method:validateField}. * * @param AphrontRequest HTTP request representing a user submitting a form * with this field in it. * @return this * @task edit */ public function setValueFromRequest(AphrontRequest $request) { throw new DifferentialFieldSpecificationIncompleteException($this); } /** * Build a renderable object (generally, some @{class:AphrontFormControl}) * which can be appended to a @{class:AphrontFormView} and represents the * interface the user sees on the "Edit Revision" screen when interacting * with this field. * * For example: * * return id(new AphrontFormTextControl()) * ->setLabel('Custom Field') * ->setName('my-custom-key') * ->setValue($this->value); * * You must implement this if you implement @{method:shouldAppearOnEdit}. * * @return AphrontView|string Something renderable. * @task edit */ public function renderEditControl() { throw new DifferentialFieldSpecificationIncompleteException($this); } /** * Optionally, build a preview panel for the field which will appear on the * edit interface. This is used for the "Summary" field, but custom fields * generally need not implement it. * * @return AphrontView|string Something renderable. * @task edit */ public function renderEditPreview() { return null; } /** * This method will be called after @{method:setValueFromRequest} but before * the field is saved. It gives you an opportunity to inspect the field value * and throw a @{class:DifferentialFieldValidationException} if there is a * problem with the value the user has provided (for example, the value the * user entered is not correctly formatted). This method is also called after * @{method:setValueFromParsedCommitMessage} before the revision is saved. * * By default, fields are not validated. * * @return void * @task edit */ public function validateField() { return; } /** * Determine if user mentions should be extracted from the value and added to * CC when creating revision. Mentions are then extracted from the string * returned by @{method:renderValueForCommitMessage}. * * By default, mentions are not extracted. * * @return bool * @task edit */ public function shouldExtractMentions() { return false; } /* -( Extending the Revision View Interface )------------------------------ */ /** * Determine if this field should appear on the revision detail view * interface. One use of this interface is to add purely informational * fields to the revision view, without any sort of backing storage. * * If you return true from this method, you must implement the methods * @{method:renderLabelForRevisionView} and * @{method:renderValueForRevisionView}. * * @return bool True if this field should appear when viewing a revision. * @task view */ public function shouldAppearOnRevisionView() { return false; } /** * Return a string field label which will appear in the revision detail * table. * * You must implement this method if you return true from * @{method:shouldAppearOnRevisionView}. * * @return string Label for field in revision detail view. * @task view */ public function renderLabelForRevisionView() { throw new DifferentialFieldSpecificationIncompleteException($this); } /** * Return a markup block representing the field for the revision detail * view. Note that you can return null to suppress display (for instance, * if the field shows related objects of some type and the revision doesn't * have any related objects). * * You must implement this method if you return true from * @{method:shouldAppearOnRevisionView}. * * @return string|null Display markup for field value, or null to suppress * field rendering. * @task view */ public function renderValueForRevisionView() { throw new DifferentialFieldSpecificationIncompleteException($this); } /** * Load users, their current statuses and return a markup with links to the * user profiles and information about their current status. * * @return string Display markup. * @task view */ public function renderUserList(array $user_phids) { if (!$user_phids) { return phutil_tag('em', array(), pht('None')); } return implode_selected_handle_links(', ', $this->getLoadedHandles(), $user_phids); } /** * Return a markup block representing a warning to display with the comment * box when preparing to accept a diff. A return value of null indicates no * warning box should be displayed for this field. * * @return string|null Display markup for warning box, or null for no warning */ public function renderWarningBoxForRevisionAccept() { return null; } /* -( Extending the Revision List Interface )------------------------------ */ /** * Determine if this field should appear in the table on the revision list * interface. * * @return bool True if this field should appear in the table. * * @task list */ public function shouldAppearOnRevisionList() { return false; } /** * Return a column header for revision list tables. * * @return string Column header. * * @task list */ public function renderHeaderForRevisionList() { throw new DifferentialFieldSpecificationIncompleteException($this); } /** * Optionally, return a column class for revision list tables. * * @return string CSS class for table cells. * * @task list */ public function getColumnClassForRevisionList() { return null; } /** * Return a table cell value for revision list tables. * * @param DifferentialRevision The revision to render a value for. * @return string Table cell value. * * @task list */ public function renderValueForRevisionList(DifferentialRevision $revision) { throw new DifferentialFieldSpecificationIncompleteException($this); } /* -( Extending the Diff View Interface )------------------------------ */ /** * Determine if this field should appear on the diff detail view * interface. One use of this interface is to add purely informational * fields to the diff view, without any sort of backing storage. * * NOTE: These diffs are not necessarily attached yet to a revision. * As such, a field on the diff view can not rely on the existence of a * revision or use storage attached to the revision. * * If you return true from this method, you must implement the methods * @{method:renderLabelForDiffView} and * @{method:renderValueForDiffView}. * * @return bool True if this field should appear when viewing a diff. * @task view */ public function shouldAppearOnDiffView() { return false; } /** * Return a string field label which will appear in the diff detail * table. * * You must implement this method if you return true from * @{method:shouldAppearOnDiffView}. * * @return string Label for field in revision detail view. * @task view */ public function renderLabelForDiffView() { throw new DifferentialFieldSpecificationIncompleteException($this); } /** * Return a markup block representing the field for the diff detail * view. Note that you can return null to suppress display (for instance, * if the field shows related objects of some type and the revision doesn't * have any related objects). * * You must implement this method if you return true from * @{method:shouldAppearOnDiffView}. * * @return string|null Display markup for field value, or null to suppress * field rendering. * @task view */ public function renderValueForDiffView() { throw new DifferentialFieldSpecificationIncompleteException($this); } /* -( Extending the Search Interface )------------------------------------ */ /** * @task search */ public function shouldAddToSearchIndex() { return false; } /** * @task search */ public function getValueForSearchIndex() { throw new DifferentialFieldSpecificationIncompleteException($this); } /** * NOTE: Keys *must be* 4 characters for * @{class:PhabricatorSearchEngineMySQL}. * * @task search */ public function getKeyForSearchIndex() { throw new DifferentialFieldSpecificationIncompleteException($this); } /* -( Extending Commit Messages )------------------------------------------ */ /** * Determine if this field should appear in commit messages. You should return * true if this field participates in any part of the commit message workflow, * even if it is not rendered by default. * * If you implement this method, you must implement * @{method:getCommitMessageKey} and * @{method:setValueFromParsedCommitMessage}. * * @return bool True if this field appears in commit messages in any capacity. * @task commit */ public function shouldAppearOnCommitMessage() { return false; } /** * Key which identifies this field in parsed commit messages. Commit messages * exist in two forms: raw textual commit messages and parsed dictionaries of * fields. This method must return a unique string which identifies this field * in dictionaries. Principally, this dictionary is shipped to and from arc * over Conduit. Keys should be appropriate property names, like "testPlan" * (not "Test Plan") and must be globally unique. * * You must implement this method if you return true from * @{method:shouldAppearOnCommitMessage}. * * @return string Key which identifies the field in dictionaries. * @task commit */ public function getCommitMessageKey() { throw new DifferentialFieldSpecificationIncompleteException($this); } /** * Set this field's value from a value in a parsed commit message dictionary. * Afterward, this field will go through the normal write workflows and the * change will be permanently stored via either the storage mechanisms (if * your field implements them), revision write hooks (if your field implements * them) or discarded (if your field implements neither, e.g. is just a * display field). * * The value you receive will either be null or something you originally * returned from @{method:parseValueFromCommitMessage}. * * You must implement this method if you return true from * @{method:shouldAppearOnCommitMessage}. * * @param mixed Field value from a parsed commit message dictionary. * @return this * @task commit */ public function setValueFromParsedCommitMessage($value) { throw new DifferentialFieldSpecificationIncompleteException($this); } /** * In revision control systems which read revision information from the * working copy, the user may edit the commit message outside of invoking * "arc diff --edit". When they do this, only some fields (those fields which * can not be edited by other users) are safe to overwrite. For instance, it * is fine to overwrite "Summary" because no one else can edit it, but not * to overwrite "Reviewers" because reviewers may have been added or removed * via the web interface. * * If a field is safe to overwrite when edited in a working copy commit * message, return true. If the authoritative value should always be used, * return false. By default, fields can not be overwritten. * * arc will only attempt to overwrite field values if run with "--verbatim". * * @return bool True to indicate the field is save to overwrite. * @task commit */ public function shouldOverwriteWhenCommitMessageIsEdited() { return false; } /** * Return true if this field should be suggested to the user during * "arc diff --edit". Basicially, return true if the field is something the * user might want to fill out (like "Summary"), and false if it's a * system/display/readonly field (like "Differential Revision"). If this * method returns true, the field will be rendered even if it has no value * during edit and update operations. * * @return bool True to indicate the field should appear in the edit template. * @task commit */ public function shouldAppearOnCommitMessageTemplate() { return true; } /** * Render a human-readable label for this field, like "Summary" or * "Test Plan". This is distinct from the commit message key, but generally * they should be similar. * * @return string Human-readable field label for commit messages. * @task commit */ public function renderLabelForCommitMessage() { throw new DifferentialFieldSpecificationIncompleteException($this); } /** * Render a human-readable value for this field when it appears in commit * messages (for instance, lists of users should be rendered as user names). * * The ##$is_edit## parameter allows you to distinguish between commit * messages being rendered for editing and those being rendered for amending * or commit. Some fields may decline to render a value in one mode (for * example, "Reviewed By" appears only when doing commit/amend, not while * editing). * * @param bool True if the message is being edited. * @return string Human-readable field value. * @task commit */ public function renderValueForCommitMessage($is_edit) { throw new DifferentialFieldSpecificationIncompleteException($this); } /** * Return one or more labels which this field parses in commit messages. For * example, you might parse all of "Task", "Tasks" and "Task Numbers" or * similar. This is just to make it easier to get commit messages to parse * when users are typing in the fields manually as opposed to using a * template, by accepting alternate spellings / pluralizations / etc. By * default, only the label returned from @{method:renderLabelForCommitMessage} * is parsed. * * @return list List of supported labels that this field can parse from commit * messages. * @task commit */ public function getSupportedCommitMessageLabels() { return array($this->renderLabelForCommitMessage()); } /** * Parse a raw text block from a commit message into a canonical * representation of the field value. For example, the "CC" field accepts a * comma-delimited list of usernames and emails and parses them into valid * PHIDs, emitting a PHID list. * * If you encounter errors (like a nonexistent username) while parsing, * you should throw a @{class:DifferentialFieldParseException}. * * Generally, this method should accept whatever you return from * @{method:renderValueForCommitMessage} and parse it back into a sensible * representation. * * You must implement this method if you return true from * @{method:shouldAppearOnCommitMessage}. * * @param string * @return mixed The canonical representation of the field value. For example, * you should lookup usernames and object references. * @task commit */ public function parseValueFromCommitMessage($value) { throw new DifferentialFieldSpecificationIncompleteException($this); } - /** - * This method allows you to take action when a commit appears in a tracked - * branch (for example, by closing tasks associated with the commit). - * - * @param PhabricatorRepository The repository the commit appeared in. - * @param PhabricatorRepositoryCommit The commit itself. - * @param PhabricatorRepostioryCommitData Commit data. - * @return void - * - * @task commit - */ - public function didParseCommit( - PhabricatorRepository $repo, - PhabricatorRepositoryCommit $commit, - PhabricatorRepositoryCommitData $data) { - return; - } - - /* -( Loading Additional Data )-------------------------------------------- */ /** * Specify which @{class:PhabricatorObjectHandle}s need to be loaded for your * field to render correctly. * * This is a convenience method which makes the handles available on all * interfaces where the field appears. If your field needs handles on only * some interfaces (or needs different handles on different interfaces) you * can overload the more specific methods to customize which interfaces you * retrieve handles for. Requesting only the handles you need will improve * the performance of your field. * * You can later retrieve these handles by calling @{method:getHandle}. * * @return list List of PHIDs to load handles for. * @task load */ protected function getRequiredHandlePHIDs() { return array(); } /** * Specify which @{class:PhabricatorObjectHandle}s need to be loaded for your * field to render correctly on the view interface. * * This is a more specific version of @{method:getRequiredHandlePHIDs} which * can be overridden to improve field performance by loading only data you * need. * * @return list List of PHIDs to load handles for. * @task load */ public function getRequiredHandlePHIDsForRevisionView() { return $this->getRequiredHandlePHIDs(); } /** * Specify which @{class:PhabricatorObjectHandle}s need to be loaded for your * field to render correctly on the list interface. * * This is a more specific version of @{method:getRequiredHandlePHIDs} which * can be overridden to improve field performance by loading only data you * need. * * @param DifferentialRevision The revision to pull PHIDs for. * @return list List of PHIDs to load handles for. * @task load */ public function getRequiredHandlePHIDsForRevisionList( DifferentialRevision $revision) { return array(); } /** * Specify which @{class:PhabricatorObjectHandle}s need to be loaded for your * field to render correctly on the edit interface. * * This is a more specific version of @{method:getRequiredHandlePHIDs} which * can be overridden to improve field performance by loading only data you * need. * * @return list List of PHIDs to load handles for. * @task load */ public function getRequiredHandlePHIDsForRevisionEdit() { return $this->getRequiredHandlePHIDs(); } /** * Specify which @{class:PhabricatorObjectHandle}s need to be loaded for your * field to render correctly on the commit message interface. * * This is a more specific version of @{method:getRequiredHandlePHIDs} which * can be overridden to improve field performance by loading only data you * need. * * @return list List of PHIDs to load handles for. * @task load */ public function getRequiredHandlePHIDsForCommitMessage() { return $this->getRequiredHandlePHIDs(); } /** * Parse a list of users into a canonical PHID list. * * @param string Raw list of comma-separated user names. * @return list List of corresponding PHIDs. * @task load */ protected function parseCommitMessageUserList($value) { return $this->parseCommitMessageObjectList($value, $mailables = false); } protected function parseCommitMessageUserOrProjectList($value) { return $this->parseCommitMessageObjectList( $value, $mailables = false, $allow_partial = false); } /** * Parse a list of mailable objects into a canonical PHID list. * * @param string Raw list of comma-separated mailable names. * @return list List of corresponding PHIDs. * @task load */ protected function parseCommitMessageMailableList($value) { return $this->parseCommitMessageObjectList($value, $mailables = true); } /** * Parse and lookup a list of object names, converting them to PHIDs. * * @param string Raw list of comma-separated object names. * @param bool True to include mailing lists. * @param bool True to make a best effort. By default, an exception is * thrown if any item is invalid. * @return list List of corresponding PHIDs. * @task load */ public static function parseCommitMessageObjectList( $value, $include_mailables, $allow_partial = false) { $types = array( PhabricatorPeoplePHIDTypeUser::TYPECONST, PhabricatorProjectPHIDTypeProject::TYPECONST, ); if ($include_mailables) { $types[] = PhabricatorMailingListPHIDTypeList::TYPECONST; } return id(new PhabricatorObjectListQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->setAllowPartialResults($allow_partial) ->setAllowedTypes($types) ->setObjectList($value) ->execute(); } /* -( Contextual Data )---------------------------------------------------- */ /** * @task context */ final public function setRevision(DifferentialRevision $revision) { $this->revision = $revision; $this->didSetRevision(); return $this; } /** * @task context */ protected function didSetRevision() { return; } /** * @task context */ final public function setDiff(DifferentialDiff $diff) { $this->diff = $diff; return $this; } /** * @task context */ final public function setManualDiff(DifferentialDiff $diff) { $this->manualDiff = $diff; return $this; } /** * @task context */ final public function setHandles(array $handles) { assert_instances_of($handles, 'PhabricatorObjectHandle'); $this->handles = $handles; return $this; } /** * @task context */ final public function setDiffProperties(array $diff_properties) { $this->diffProperties = $diff_properties; return $this; } /** * @task context */ final public function setUser(PhabricatorUser $user) { $this->user = $user; return $this; } /** * @task context */ final protected function getRevision() { if (empty($this->revision)) { throw new DifferentialFieldDataNotAvailableException($this); } return $this->revision; } /** * Determine if revision context is currently available. * * @task context */ final protected function hasRevision() { return (bool)$this->revision; } /** * @task context */ final protected function getDiff() { if (empty($this->diff)) { throw new DifferentialFieldDataNotAvailableException($this); } return $this->diff; } /** * @task context */ final protected function getManualDiff() { if (!$this->manualDiff) { return $this->getDiff(); } return $this->manualDiff; } /** * @task context */ final protected function getUser() { if (empty($this->user)) { throw new DifferentialFieldDataNotAvailableException($this); } return $this->user; } /** * Get the handle for an object PHID. You must overload * @{method:getRequiredHandlePHIDs} (or a more specific version thereof) * and include the PHID you want in the list for it to be available here. * * @return PhabricatorObjectHandle Handle to the object. * @task context */ final protected function getHandle($phid) { if ($this->handles === null) { throw new DifferentialFieldDataNotAvailableException($this); } if (empty($this->handles[$phid])) { $class = get_class($this); throw new Exception( "A differential field (of class '{$class}') is attempting to retrieve ". "a handle ('{$phid}') which it did not request. Return all handle ". "PHIDs you need from getRequiredHandlePHIDs()."); } return $this->handles[$phid]; } final protected function getLoadedHandles() { if ($this->handles === null) { throw new DifferentialFieldDataNotAvailableException($this); } return $this->handles; } /** * Get the list of properties for a diff set by @{method:setManualDiff}. * * @return array Array of all Diff properties. * @task context */ final public function getDiffProperties() { if ($this->diffProperties === null) { // This will be set to some (possibly empty) array if we've loaded // properties, so null means diff properties aren't available in this // context. throw new DifferentialFieldDataNotAvailableException($this); } return $this->diffProperties; } /** * Get a property of a diff set by @{method:setManualDiff}. * * @param string Diff property key. * @return mixed|null Diff property, or null if the property does not have * a value. * @task context */ final public function getDiffProperty($key) { return idx($this->getDiffProperties(), $key); } } diff --git a/src/applications/differential/field/specification/DifferentialFreeformFieldSpecification.php b/src/applications/differential/field/specification/DifferentialFreeformFieldSpecification.php index 694fbf1b62..ce7d0633ea 100644 --- a/src/applications/differential/field/specification/DifferentialFreeformFieldSpecification.php +++ b/src/applications/differential/field/specification/DifferentialFreeformFieldSpecification.php @@ -1,164 +1,6 @@ parseCorpus($message); - - $task_statuses = array(); - foreach ($matches as $match) { - $prefix = phutil_utf8_strtolower($match['prefix']); - $suffix = phutil_utf8_strtolower($match['suffix']); - - $status = idx($suffixes, $suffix); - if (!$status) { - $status = idx($prefixes, $prefix); - } - - foreach ($match['monograms'] as $task_monogram) { - $task_id = (int)trim($task_monogram, 'tT'); - $task_statuses[$task_id] = $status; - } - } - - return $task_statuses; - } - - private function findDependentRevisions($message) { - $matches = id(new DifferentialCustomFieldDependsOnParser()) - ->parseCorpus($message); - - $dependents = array(); - foreach ($matches as $match) { - foreach ($match['monograms'] as $monogram) { - $id = (int)trim($monogram, 'dD'); - $dependents[$id] = $id; - } - } - - return $dependents; - } - - public static function findRevertedCommits($message) { - $matches = id(new DifferentialCustomFieldRevertsParser()) - ->parseCorpus($message); - - $result = array(); - foreach ($matches as $match) { - foreach ($match['monograms'] as $monogram) { - $result[$monogram] = $monogram; - } - } - - return $result; - } - - private function saveFieldEdges( - DifferentialRevision $revision, - $edge_type, - array $add_phids) { - - $revision_phid = $revision->getPHID(); - - $old_phids = PhabricatorEdgeQuery::loadDestinationPHIDs( - $revision_phid, - $edge_type); - - $add_phids = array_diff($add_phids, $old_phids); - if (!$add_phids) { - return; - } - - $edge_editor = id(new PhabricatorEdgeEditor())->setActor($this->getUser()); - foreach ($add_phids as $phid) { - $edge_editor->addEdge($revision_phid, $edge_type, $phid); - } - // NOTE: Deletes only through the fields. - $edge_editor->save(); - } - - public function didParseCommit( - PhabricatorRepository $repository, - PhabricatorRepositoryCommit $commit, - PhabricatorRepositoryCommitData $data) { - - $message = $this->renderValueForCommitMessage($is_edit = false); - - $user = id(new PhabricatorUser())->loadOneWhere( - 'phid = %s', - $data->getCommitDetail('authorPHID')); - if (!$user) { - // TODO: Maybe after grey users, we should find a way to proceed even - // if we don't know who the author is. - return; - } - - $commit_names = self::findRevertedCommits($message); - if ($commit_names) { - $reverts = id(new DiffusionCommitQuery()) - ->setViewer($user) - ->withIdentifiers($commit_names) - ->withDefaultRepository($repository) - ->execute(); - foreach ($reverts as $revert) { - // TODO: Do interesting things here. - } - } - - $tasks_statuses = $this->findMentionedTasks($message); - if (!$tasks_statuses) { - return; - } - - $tasks = id(new ManiphestTaskQuery()) - ->setViewer($user) - ->withIDs(array_keys($tasks_statuses)) - ->execute(); - - foreach ($tasks as $task_id => $task) { - id(new PhabricatorEdgeEditor()) - ->setActor($user) - ->addEdge( - $task->getPHID(), - PhabricatorEdgeConfig::TYPE_TASK_HAS_COMMIT, - $commit->getPHID()) - ->save(); - - $status = $tasks_statuses[$task_id]; - if (!$status) { - // Text like "Ref T123", don't change the task status. - continue; - } - - if ($task->getStatus() == $status) { - // Task is already in the specified status, so skip updating it. - continue; - } - - $commit_name = $repository->formatCommitName( - $commit->getCommitIdentifier()); - - $call = new ConduitCall( - 'maniphest.update', - array( - 'id' => $task->getID(), - 'status' => $status, - 'comments' => "Closed by commit {$commit_name}.", - )); - - $call->setUser($user); - $call->execute(); - } - } - } diff --git a/src/applications/releeph/differential/DifferentialReleephRequestFieldSpecification.php b/src/applications/releeph/differential/DifferentialReleephRequestFieldSpecification.php index 982c166a14..1ec037c3ef 100644 --- a/src/applications/releeph/differential/DifferentialReleephRequestFieldSpecification.php +++ b/src/applications/releeph/differential/DifferentialReleephRequestFieldSpecification.php @@ -1,366 +1,368 @@ " headers in commits created by * arc-releeph so that RQs committed by arc-releeph have real * PhabricatorRepositoryCommits associated with them (instaed of just the SHA * of the commit, as seen by the pusher). * * 2: If requestors want to commit directly to their release branch, they can * use this header to (i) indicate on a differential revision that this * differential revision is for the release branch, and (ii) when they land * their diff on to the release branch manually, the ReleephRequest is * automatically updated (instead of having to use the "Mark Manually Picked" * button.) * */ final class DifferentialReleephRequestFieldSpecification extends DifferentialFieldSpecification { const ACTION_PICKS = 'picks'; const ACTION_REVERTS = 'reverts'; private $releephAction; private $releephPHIDs = array(); public function getStorageKey() { return 'releeph:actions'; } public function getValueForStorage() { return json_encode(array( 'releephAction' => $this->releephAction, 'releephPHIDs' => $this->releephPHIDs, )); } public function setValueFromStorage($json) { if ($json) { $dict = json_decode($json, true); $this->releephAction = idx($dict, 'releephAction'); $this->releephPHIDs = idx($dict, 'releephPHIDs'); } return $this; } public function shouldAppearOnRevisionView() { return true; } public function renderLabelForRevisionView() { return 'Releeph'; } public function getRequiredHandlePHIDs() { return mpull($this->loadReleephRequests(), 'getPHID'); } public function renderValueForRevisionView() { static $tense = array( self::ACTION_PICKS => array( 'future' => 'Will pick', 'past' => 'Picked', ), self::ACTION_REVERTS => array( 'future' => 'Will revert', 'past' => 'Reverted', ), ); $releeph_requests = $this->loadReleephRequests(); if (!$releeph_requests) { return null; } $status = $this->getRevision()->getStatus(); if ($status == ArcanistDifferentialRevisionStatus::CLOSED) { $verb = $tense[$this->releephAction]['past']; } else { $verb = $tense[$this->releephAction]['future']; } $parts = hsprintf('%s...', $verb); foreach ($releeph_requests as $releeph_request) { $parts->appendHTML(phutil_tag('br')); $parts->appendHTML( $this->getHandle($releeph_request->getPHID())->renderLink()); } return $parts; } public function shouldAppearOnCommitMessage() { return true; } public function getCommitMessageKey() { return 'releephActions'; } public function setValueFromParsedCommitMessage($dict) { $this->releephAction = $dict['releephAction']; $this->releephPHIDs = $dict['releephPHIDs']; return $this; } public function renderValueForCommitMessage($is_edit) { $releeph_requests = $this->loadReleephRequests(); if (!$releeph_requests) { return null; } $parts = array($this->releephAction); foreach ($releeph_requests as $releeph_request) { $parts[] = 'RQ'.$releeph_request->getID(); } return implode(' ', $parts); } /** * Releeph fields should look like: * * Releeph: picks RQ1 RQ2, RQ3 * Releeph: reverts RQ1 */ public function parseValueFromCommitMessage($value) { /** * Releeph commit messages look like this (but with more blank lines, * omitted here): * * Make CaptainHaddock more reasonable * Releeph: picks RQ1 * Requested By: edward * Approved By: edward (requestor) * Request Reason: x * Summary: Make the Haddock implementation more reasonable. * Test Plan: none * Reviewers: user1 * * Some of these fields are recognized by Differential (e.g. "Requested * By"). They are folded up into the "Releeph" field, parsed by this * class. As such $value includes more than just the first-line: * * "picks RQ1\n\nRequested By: edward\n\nApproved By: edward (requestor)" * * To hack around this, just consider the first line of $value when * determining what Releeph actions the parsed commit is performing. */ $first_line = head(array_filter(explode("\n", $value))); $tokens = preg_split('/\s*,?\s+/', $first_line); $raw_action = array_shift($tokens); $action = strtolower($raw_action); if (!$action) { return null; } switch ($action) { case self::ACTION_REVERTS: case self::ACTION_PICKS: break; default: throw new DifferentialFieldParseException( "Commit message contains unknown Releeph action '{$raw_action}'!"); break; } $releeph_requests = array(); foreach ($tokens as $token) { $match = array(); if (!preg_match('/^(?:RQ)?(\d+)$/i', $token, $match)) { $label = $this->renderLabelForCommitMessage(); throw new DifferentialFieldParseException( "Commit message contains unparseable ". "Releeph request token '{$token}'!"); } $id = (int) $match[1]; $releeph_request = id(new ReleephRequest())->load($id); if (!$releeph_request) { throw new DifferentialFieldParseException( "Commit message references non existent releeph request: {$value}!"); } $releeph_requests[] = $releeph_request; } if (count($releeph_requests) > 1) { $rqs_seen = array(); $groups = array(); foreach ($releeph_requests as $releeph_request) { $releeph_branch = $releeph_request->loadReleephBranch(); $branch_name = $releeph_branch->getName(); $rq_id = 'RQ'.$releeph_request->getID(); if (idx($rqs_seen, $rq_id)) { throw new DifferentialFieldParseException( "Commit message refers to {$rq_id} multiple times!"); } $rqs_seen[$rq_id] = true; if (!isset($groups[$branch_name])) { $groups[$branch_name] = array(); } $groups[$branch_name][] = $rq_id; } if (count($groups) > 1) { $lists = array(); foreach ($groups as $branch_name => $rq_ids) { $lists[] = implode(', ', $rq_ids).' in '.$branch_name; } throw new DifferentialFieldParseException( "Commit message references multiple Releeph requests, ". "but the requests are in different branches: ". implode('; ', $lists)); } } $phids = mpull($releeph_requests, 'getPHID'); $data = array( 'releephAction' => $action, 'releephPHIDs' => $phids, ); return $data; } public function renderLabelForCommitMessage() { return 'Releeph'; } public function shouldAppearOnCommitMessageTemplate() { return false; } public function didParseCommit(PhabricatorRepository $repo, PhabricatorRepositoryCommit $commit, PhabricatorRepositoryCommitData $data) { + // NOTE: This is currently dead code. See T2222. + $releeph_requests = $this->loadReleephRequests(); if (!$releeph_requests) { return; } $releeph_branch = head($releeph_requests)->loadReleephBranch(); if (!$this->isCommitOnBranch($repo, $commit, $releeph_branch)) { return; } foreach ($releeph_requests as $releeph_request) { if ($this->releephAction === self::ACTION_PICKS) { $action = 'pick'; } else { $action = 'revert'; } $actor_phid = coalesce( $data->getCommitDetail('committerPHID'), $data->getCommitDetail('authorPHID')); $actor = id(new PhabricatorUser()) ->loadOneWhere('phid = %s', $actor_phid); $xactions = array(); $xactions[] = id(new ReleephRequestTransaction()) ->setTransactionType(ReleephRequestTransaction::TYPE_DISCOVERY) ->setMetadataValue('action', $action) ->setMetadataValue('authorPHID', $data->getCommitDetail('authorPHID')) ->setMetadataValue('committerPHID', $data->getCommitDetail('committerPHID')) ->setNewValue($commit->getPHID()); $editor = id(new ReleephRequestTransactionalEditor()) ->setActor($actor) ->setContinueOnNoEffect(true) ->setContentSource( PhabricatorContentSource::newForSource( PhabricatorContentSource::SOURCE_UNKNOWN, array())); $editor->applyTransactions($releeph_request, $xactions); } } private function loadReleephRequests() { if (!$this->releephPHIDs) { return array(); } else { return id(new ReleephRequest()) ->loadAllWhere('phid IN (%Ls)', $this->releephPHIDs); } } private function isCommitOnBranch(PhabricatorRepository $repo, PhabricatorRepositoryCommit $commit, ReleephBranch $releeph_branch) { switch ($repo->getVersionControlSystem()) { case PhabricatorRepositoryType::REPOSITORY_TYPE_GIT: list($output) = $repo->execxLocalCommand( 'branch --all --no-color --contains %s', $commit->getCommitIdentifier()); $remote_prefix = 'remotes/origin/'; $branches = array(); foreach (array_filter(explode("\n", $output)) as $line) { $tokens = explode(' ', $line); $ref = last($tokens); if (strncmp($ref, $remote_prefix, strlen($remote_prefix)) === 0) { $branch = substr($ref, strlen($remote_prefix)); $branches[$branch] = $branch; } } return idx($branches, $releeph_branch->getName()); break; case PhabricatorRepositoryType::REPOSITORY_TYPE_SVN: $change_query = DiffusionPathChangeQuery::newFromDiffusionRequest( DiffusionRequest::newFromDictionary(array( 'user' => $this->getUser(), 'repository' => $repo, 'commit' => $commit->getCommitIdentifier(), ))); $path_changes = $change_query->loadChanges(); $commit_paths = mpull($path_changes, 'getPath'); $branch_path = $releeph_branch->getName(); $in_branch = array(); $ex_branch = array(); foreach ($commit_paths as $path) { if (strncmp($path, $branch_path, strlen($branch_path)) === 0) { $in_branch[] = $path; } else { $ex_branch[] = $path; } } if ($in_branch && $ex_branch) { $error = sprintf( "CONFUSION: commit %s in %s contains %d path change(s) that were ". "part of a Releeph branch, but also has %d path change(s) not ". "part of a Releeph branch!", $commit->getCommitIdentifier(), $repo->getCallsign(), count($in_branch), count($ex_branch)); phlog($error); } return !empty($in_branch); break; } } } diff --git a/src/applications/repository/worker/commitmessageparser/PhabricatorRepositoryCommitMessageParserWorker.php b/src/applications/repository/worker/commitmessageparser/PhabricatorRepositoryCommitMessageParserWorker.php index fa7bb122e7..33fe94bc5a 100644 --- a/src/applications/repository/worker/commitmessageparser/PhabricatorRepositoryCommitMessageParserWorker.php +++ b/src/applications/repository/worker/commitmessageparser/PhabricatorRepositoryCommitMessageParserWorker.php @@ -1,421 +1,510 @@ commit; $author = $ref->getAuthor(); $message = $ref->getMessage(); $committer = $ref->getCommitter(); $hashes = $ref->getHashes(); $data = id(new PhabricatorRepositoryCommitData())->loadOneWhere( 'commitID = %d', $commit->getID()); if (!$data) { $data = new PhabricatorRepositoryCommitData(); } $data->setCommitID($commit->getID()); $data->setAuthorName($author); $data->setCommitDetail( 'authorPHID', $this->resolveUserPHID($commit, $author)); $data->setCommitMessage($message); if (strlen($committer)) { $data->setCommitDetail('committer', $committer); $data->setCommitDetail( 'committerPHID', $this->resolveUserPHID($commit, $committer)); } $repository = $this->repository; $author_phid = $data->getCommitDetail('authorPHID'); $committer_phid = $data->getCommitDetail('committerPHID'); $user = new PhabricatorUser(); if ($author_phid) { $user = $user->loadOneWhere( 'phid = %s', $author_phid); } $field_values = id(new DiffusionLowLevelCommitFieldsQuery()) ->setRepository($repository) ->withCommitRef($ref) ->execute(); $revision_id = idx($field_values, 'revisionID'); if (!empty($field_values['reviewedByPHIDs'])) { $data->setCommitDetail( 'reviewerPHID', reset($field_values['reviewedByPHIDs'])); } $data->setCommitDetail('differential.revisionID', $revision_id); if ($author_phid != $commit->getAuthorPHID()) { $commit->setAuthorPHID($author_phid); } $commit->setSummary($data->getSummary()); $commit->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 = null; $should_autoclose = $repository->shouldAutocloseCommit($commit, $data); if ($revision_id) { // TODO: Check if a more restrictive viewer could be set here $revision_query = id(new DifferentialRevisionQuery()) ->withIDs(array($revision_id)) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->needReviewerStatus(true) ->needActiveDiffs(true); - // TODO: Remove this once we swap to new CustomFields. This is only - // required by the old FieldSpecifications, lower on in this worker. - $revision_query->needRelationships(true); - $revision = $revision_query->executeOne(); if ($revision) { $commit_drev = PhabricatorEdgeConfig::TYPE_COMMIT_HAS_DREV; id(new PhabricatorEdgeEditor()) ->setActor($user) ->addEdge($commit->getPHID(), $commit_drev, $revision->getPHID()) ->save(); queryfx( $conn_w, 'INSERT IGNORE INTO %T (revisionID, commitPHID) VALUES (%d, %s)', DifferentialRevision::TABLE_COMMIT, $revision->getID(), $commit->getPHID()); $status_closed = ArcanistDifferentialRevisionStatus::CLOSED; $should_close = ($revision->getStatus() != $status_closed) && $should_autoclose; if ($should_close) { $actor_phid = nonempty( $committer_phid, $author_phid, $revision->getAuthorPHID()); $actor = id(new PhabricatorUser()) ->loadOneWhere('phid = %s', $actor_phid); $commit_name = $repository->formatCommitName( $commit->getCommitIdentifier()); $committer_name = $this->loadUserName( $committer_phid, $data->getCommitDetail('committer'), $actor); $author_name = $this->loadUserName( $author_phid, $data->getAuthorName(), $actor); if ($committer_name && ($committer_name != $author_name)) { $message = pht( 'Closed by commit %s (authored by %s, committed by %s).', $commit_name, $author_name, $committer_name); } else { $message = pht( 'Closed by commit %s (authored by %s).', $commit_name, $author_name); } $diff = $this->generateFinalDiff($revision, $actor_phid); $vs_diff = $this->loadChangedByCommit($revision, $diff); $changed_uri = null; if ($vs_diff) { $data->setCommitDetail('vsDiff', $vs_diff->getID()); $changed_uri = PhabricatorEnv::getProductionURI( '/D'.$revision->getID(). '?vs='.$vs_diff->getID(). '&id='.$diff->getID(). '#toc'); } $xactions = array(); $xactions[] = id(new DifferentialTransaction()) ->setTransactionType(DifferentialTransaction::TYPE_ACTION) ->setNewValue(DifferentialAction::ACTION_CLOSE); $xactions[] = id(new DifferentialTransaction()) ->setTransactionType(DifferentialTransaction::TYPE_UPDATE) ->setIgnoreOnNoEffect(true) ->setNewValue($diff->getPHID()); $xactions[] = id(new DifferentialTransaction()) ->setTransactionType(PhabricatorTransactions::TYPE_COMMENT) ->setIgnoreOnNoEffect(true) ->attachComment( id(new DifferentialTransactionComment()) ->setContent($message)); $content_source = PhabricatorContentSource::newForSource( PhabricatorContentSource::SOURCE_DAEMON, array()); $editor = id(new DifferentialTransactionEditor()) ->setActor($actor) ->setContinueOnMissingFields(true) ->setContentSource($content_source) ->setChangedPriorToCommitURI($changed_uri) ->setIsCloseByCommit(true); try { $editor->applyTransactions($revision, $xactions); } catch (PhabricatorApplicationTransactionNoEffectException $ex) { // NOTE: We've marked transactions other than the CLOSE transaction // as ignored when they don't have an effect, so this means that we // lost a race to close the revision. That's perfectly fine, we can // just continue normally. } } } } if ($should_autoclose) { - // TODO: When this is moved to CustomFields, remove the additional - // call above in query construction. - $fields = DifferentialFieldSelector::newSelector() - ->getFieldSpecifications(); - foreach ($fields as $key => $field) { - if (!$field->shouldAppearOnCommitMessage()) { - continue; - } - $field->setUser($user); - $value = idx($field_values, $field->getCommitMessageKey()); - $field->setValueFromParsedCommitMessage($value); - if ($revision) { - $field->setRevision($revision); - } - $field->didParseCommit($repository, $commit, $data); + // TODO: This isn't as general as it could be. + if ($user->getPHID()) { + $this->closeTasks($user, $repository, $commit, $message); } } $data->save(); $commit->writeImportStatusFlag( PhabricatorRepositoryCommit::IMPORTED_MESSAGE); } private function loadUserName($user_phid, $default, PhabricatorUser $actor) { if (!$user_phid) { return $default; } $handle = id(new PhabricatorHandleQuery()) ->setViewer($actor) ->withPHIDs(array($user_phid)) ->executeOne(); return '@'.$handle->getName(); } private function generateFinalDiff( DifferentialRevision $revision, $actor_phid) { $viewer = PhabricatorUser::getOmnipotentUser(); $drequest = DiffusionRequest::newFromDictionary(array( 'user' => $viewer, 'repository' => $this->repository, )); $raw_diff = DiffusionQuery::callConduitWithDiffusionRequest( $viewer, $drequest, 'diffusion.rawdiffquery', array( 'commit' => $this->commit->getCommitIdentifier(), )); // TODO: Support adds, deletes and moves under SVN. if (strlen($raw_diff)) { $changes = id(new ArcanistDiffParser())->parseDiff($raw_diff); } else { // This is an empty diff, maybe made with `git commit --allow-empty`. // NOTE: These diffs have the same tree hash as their ancestors, so // they may attach to revisions in an unexpected way. Just let this // happen for now, although it might make sense to special case it // eventually. $changes = array(); } $diff = DifferentialDiff::newFromRawChanges($changes) ->setRepositoryPHID($this->repository->getPHID()) ->setAuthorPHID($actor_phid) ->setCreationMethod('commit') ->setSourceControlSystem($this->repository->getVersionControlSystem()) ->setLintStatus(DifferentialLintStatus::LINT_SKIP) ->setUnitStatus(DifferentialUnitStatus::UNIT_SKIP) ->setDateCreated($this->commit->getEpoch()) ->setDescription( 'Commit r'. $this->repository->getCallsign(). $this->commit->getCommitIdentifier()); // TODO: This is not correct in SVN where one repository can have multiple // Arcanist projects. $arcanist_project = id(new PhabricatorRepositoryArcanistProject()) ->loadOneWhere('repositoryID = %d LIMIT 1', $this->repository->getID()); if ($arcanist_project) { $diff->setArcanistProjectPHID($arcanist_project->getPHID()); } $parents = DiffusionQuery::callConduitWithDiffusionRequest( $viewer, $drequest, 'diffusion.commitparentsquery', array( 'commit' => $this->commit->getCommitIdentifier(), )); if ($parents) { $diff->setSourceControlBaseRevision(head($parents)); } // TODO: Attach binary files. return $diff->save(); } private function loadChangedByCommit( DifferentialRevision $revision, DifferentialDiff $diff) { $repository = $this->repository; $vs_changesets = array(); $vs_diff = id(new DifferentialDiff())->loadOneWhere( 'revisionID = %d AND creationMethod != %s ORDER BY id DESC LIMIT 1', $revision->getID(), 'commit'); foreach ($vs_diff->loadChangesets() as $changeset) { $path = $changeset->getAbsoluteRepositoryPath($repository, $vs_diff); $path = ltrim($path, '/'); $vs_changesets[$path] = $changeset; } $changesets = array(); foreach ($diff->getChangesets() as $changeset) { $path = $changeset->getAbsoluteRepositoryPath($repository, $diff); $path = ltrim($path, '/'); $changesets[$path] = $changeset; } if (array_fill_keys(array_keys($changesets), true) != array_fill_keys(array_keys($vs_changesets), true)) { return $vs_diff; } $hunks = id(new DifferentialHunk())->loadAllWhere( 'changesetID IN (%Ld)', mpull($vs_changesets, 'getID')); $hunks = mgroup($hunks, 'getChangesetID'); foreach ($vs_changesets as $changeset) { $changeset->attachHunks(idx($hunks, $changeset->getID(), array())); } $file_phids = array(); foreach ($vs_changesets as $changeset) { $metadata = $changeset->getMetadata(); $file_phid = idx($metadata, 'new:binary-phid'); if ($file_phid) { $file_phids[$file_phid] = $file_phid; } } $files = array(); if ($file_phids) { $files = id(new PhabricatorFileQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withPHIDs($file_phids) ->execute(); $files = mpull($files, null, 'getPHID'); } foreach ($changesets as $path => $changeset) { $vs_changeset = $vs_changesets[$path]; $file_phid = idx($vs_changeset->getMetadata(), 'new:binary-phid'); if ($file_phid) { if (!isset($files[$file_phid])) { return $vs_diff; } $drequest = DiffusionRequest::newFromDictionary(array( 'user' => PhabricatorUser::getOmnipotentUser(), 'initFromConduit' => false, 'repository' => $this->repository, 'commit' => $this->commit->getCommitIdentifier(), 'path' => $path, )); $corpus = DiffusionFileContentQuery::newFromDiffusionRequest($drequest) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->loadFileContent() ->getCorpus(); if ($files[$file_phid]->loadFileData() != $corpus) { return $vs_diff; } } else { $context = implode("\n", $changeset->makeChangesWithContext()); $vs_context = implode("\n", $vs_changeset->makeChangesWithContext()); // We couldn't just compare $context and $vs_context because following // diffs will be considered different: // // -(empty line) // -echo 'test'; // (empty line) // // (empty line) // -echo "test"; // -(empty line) $hunk = id(new DifferentialHunk())->setChanges($context); $vs_hunk = id(new DifferentialHunk())->setChanges($vs_context); if ($hunk->makeOldFile() != $vs_hunk->makeOldFile() || $hunk->makeNewFile() != $vs_hunk->makeNewFile()) { return $vs_diff; } } } return null; } private function resolveUserPHID( PhabricatorRepositoryCommit $commit, $user_name) { return id(new DiffusionResolveUserQuery()) ->withCommit($commit) ->withName($user_name) ->execute(); } + private function closeTasks( + PhabricatorUser $actor, + PhabricatorRepository $repository, + PhabricatorRepositoryCommit $commit, + $message) { + + $maniphest = 'PhabricatorApplicationManiphest'; + if (!PhabricatorApplication::isClassInstalled($maniphest)) { + return; + } + + $prefixes = ManiphestTaskStatus::getStatusPrefixMap(); + $suffixes = ManiphestTaskStatus::getStatusSuffixMap(); + + $matches = id(new ManiphestCustomFieldStatusParser()) + ->parseCorpus($message); + + $task_statuses = array(); + foreach ($matches as $match) { + $prefix = phutil_utf8_strtolower($match['prefix']); + $suffix = phutil_utf8_strtolower($match['suffix']); + + $status = idx($suffixes, $suffix); + if (!$status) { + $status = idx($prefixes, $prefix); + } + + foreach ($match['monograms'] as $task_monogram) { + $task_id = (int)trim($task_monogram, 'tT'); + $task_statuses[$task_id] = $status; + } + } + + if (!$task_statuses) { + return; + } + + $tasks = id(new ManiphestTaskQuery()) + ->setViewer($actor) + ->withIDs(array_keys($task_statuses)) + ->execute(); + + foreach ($tasks as $task_id => $task) { + $xactions = array(); + + // TODO: Swap this for a real edge transaction once the weirdness in + // Maniphest edges is sorted out. Currently, Maniphest reacts to an edge + // edit on this edge. + id(new PhabricatorEdgeEditor()) + ->setActor($actor) + ->addEdge( + $task->getPHID(), + PhabricatorEdgeConfig::TYPE_TASK_HAS_COMMIT, + $commit->getPHID()) + ->save(); + + /* TODO: Do this instead of the above. + + $xactions[] = id(new ManiphestTransaction()) + ->setTransactionType(PhabricatorTransactions::TYPE_EDGE) + ->setMetadataValue('edge:type', $edge_task_has_commit) + ->setNewValue( + array( + '+' => array( + $commit->getPHID() => $commit->getPHID(), + ), + )); + */ + + $status = $task_statuses[$task_id]; + if ($status) { + if ($task->getStatus() != $status) { + $xactions[] = id(new ManiphestTransaction()) + ->setTransactionType(ManiphestTransaction::TYPE_STATUS) + ->setNewValue($status); + + $commit_name = $repository->formatCommitName( + $commit->getCommitIdentifier()); + + $status_message = pht( + 'Closed by commit %s.', + $commit_name); + + $xactions[] = id(new ManiphestTransaction()) + ->setTransactionType(PhabricatorTransactions::TYPE_COMMENT) + ->attachComment( + id(new ManiphestTransactionComment()) + ->setContent($status_message)); + } + } + + $content_source = PhabricatorContentSource::newForSource( + PhabricatorContentSource::SOURCE_DAEMON, + array()); + + $editor = id(new ManiphestTransactionEditor()) + ->setActor($actor) + ->setContinueOnNoEffect(true) + ->setContinueOnMissingFields(true) + ->setContentSource($content_source); + + $editor->applyTransactions($task, $xactions); + } + } + }