diff --git a/src/applications/differential/editor/DifferentialRevisionEditor.php b/src/applications/differential/editor/DifferentialRevisionEditor.php index ee1b23ff9c..e4dc8db969 100644 --- a/src/applications/differential/editor/DifferentialRevisionEditor.php +++ b/src/applications/differential/editor/DifferentialRevisionEditor.php @@ -1,943 +1,955 @@ revision = $revision; } public static function newRevisionFromConduitWithDiff( array $fields, DifferentialDiff $diff, PhabricatorUser $actor) { $revision = new DifferentialRevision(); $revision->setPHID($revision->generatePHID()); $revision->setAuthorPHID($actor->getPHID()); $revision->setStatus(ArcanistDifferentialRevisionStatus::NEEDS_REVIEW); $editor = new DifferentialRevisionEditor($revision); $editor->setActor($actor); $editor->copyFieldsFromConduit($fields); $editor->addDiff($diff, null); $editor->save(); return $revision; } public function copyFieldsFromConduit(array $fields) { $actor = $this->getActor(); $revision = $this->revision; $revision->loadRelationships(); $aux_fields = DifferentialFieldSelector::newSelector() ->getFieldSpecifications(); foreach ($aux_fields as $key => $aux_field) { $aux_field->setRevision($revision); $aux_field->setUser($actor); if (!$aux_field->shouldAppearOnCommitMessage()) { unset($aux_fields[$key]); } } $aux_fields = mpull($aux_fields, null, 'getCommitMessageKey'); 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(); } $aux_fields = array_values($aux_fields); $this->setAuxiliaryFields($aux_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; 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(); } /** * A silent update does not trigger Herald rules or send emails. This is used * for auto-amends at commit time. */ public function setSilentUpdate($silent) { $this->silentUpdate = $silent; return $this; } public function save() { $revision = $this->getRevision(); $is_new = $this->isNewRevision(); if ($is_new) { $this->initializeNewRevision($revision); } $revision->loadRelationships(); $this->willWriteRevision(); 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) { $adapter = new HeraldDifferentialRevisionAdapter( $revision, $diff); $adapter->setExplicitCCs($new['ccs']); $adapter->setExplicitReviewers($new['rev']); $adapter->setForbiddenCCs($revision->getUnsubscribedPHIDs()); $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' => array(), 'ccs' => $adapter->getCCsAddedByHerald(), ); $rem_ccs = $adapter->getCCsRemovedByHerald(); } else { $sub = array( 'rev' => array(), 'ccs' => 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]); } self::alterReviewers( $revision, $this->reviewers, array_keys($rem['rev']), array_keys($add['rev']), $this->getActorPHID()); // 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 PhabricatorObjectHandleData($phids)) ->loadHandles(); $actor_handle = $handles[$this->getActorPHID()]; $changesets = null; $comment = null; if ($diff) { $changesets = $diff->loadChangesets(); // TODO: This should probably be in DifferentialFeedbackEditor? if (!$is_new) { $comment = $this->createComment(); } if ($comment) { $mail[] = id(new DifferentialNewDiffMail( $revision, $actor_handle, $changesets)) ->setIsFirstMailAboutRevision($is_new) ->setIsFirstMailToRecipients($is_new) ->setComments($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(); $this->didWriteRevision(); $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(), ); id(new PhabricatorTimelineEvent('difx', $event_data)) ->recordEvent(); $mailed_phids = array(); if (!$this->silentUpdate) { $revision->loadRelationships(); if ($add['rev']) { $message = id(new DifferentialNewDiffMail( $revision, $actor_handle, $changesets)) ->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)) ->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(); // TODO: Move this into a worker task thing. PhabricatorSearchDifferentialIndexer::indexRevision($revision); } public static function addCCAndUpdateRevision( $revision, $phid, $reason) { self::addCC($revision, $phid, $reason); $unsubscribed = $revision->getUnsubscribed(); if (isset($unsubscribed[$phid])) { unset($unsubscribed[$phid]); $revision->setUnsubscribed($unsubscribed); $revision->save(); } } public static function removeCCAndUpdateRevision( $revision, $phid, $reason) { self::removeCC($revision, $phid, $reason); $unsubscribed = $revision->getUnsubscribed(); if (empty($unsubscribed[$phid])) { $unsubscribed[$phid] = true; $revision->setUnsubscribed($unsubscribed); $revision->save(); } } public static function addCC( DifferentialRevision $revision, $phid, $reason) { return self::alterCCs( $revision, $revision->getCCPHIDs(), $rem = array(), $add = array($phid), $reason); } public static function removeCC( DifferentialRevision $revision, $phid, $reason) { return self::alterCCs( $revision, $revision->getCCPHIDs(), $rem = array($phid), $add = array(), $reason); } 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); return self::alterRelationships( $revision, $stable_phids, $rem_phids, $add_phids, $reason_phid, DifferentialRevision::RELATION_SUBSCRIBED); } private static function getImpliedCCs(DifferentialRevision $revision) { return array_merge( $revision->getReviewers(), array($revision->getAuthorPHID())); } public static function alterReviewers( DifferentialRevision $revision, array $stable_phids, array $rem_phids, array $add_phids, $reason_phid) { return self::alterRelationships( $revision, $stable_phids, $rem_phids, $add_phids, $reason_phid, DifferentialRevision::RELATION_REVIEWER); } private static function alterRelationships( DifferentialRevision $revision, array $stable_phids, array $rem_phids, array $add_phids, $reason_phid, $relation_type) { $rem_map = array_fill_keys($rem_phids, true); $add_map = array_fill_keys($add_phids, true); $seq_map = array_values($stable_phids); $seq_map = array_flip($seq_map); foreach ($rem_map as $phid => $ignored) { if (!isset($seq_map[$phid])) { $seq_map[$phid] = count($seq_map); } } foreach ($add_map as $phid => $ignored) { if (!isset($seq_map[$phid])) { $seq_map[$phid] = count($seq_map); } } $raw = $revision->getRawRelations($relation_type); $raw = ipull($raw, null, 'objectPHID'); $sequence = count($seq_map); foreach ($raw as $phid => $ignored) { if (isset($seq_map[$phid])) { $raw[$phid]['sequence'] = $seq_map[$phid]; } else { $raw[$phid]['sequence'] = $sequence++; } } $raw = isort($raw, 'sequence'); foreach ($raw as $phid => $ignored) { if (isset($rem_map[$phid])) { unset($raw[$phid]); } } foreach ($add_phids as $add) { $reason = is_array($reason_phid) ? idx($reason_phid, $add) : $reason_phid; $raw[$add] = array( 'objectPHID' => $add, 'sequence' => idx($seq_map, $add, $sequence++), 'reasonPHID' => $reason, ); } $conn_w = $revision->establishConnection('w'); $sql = array(); foreach ($raw as $relation) { $sql[] = qsprintf( $conn_w, '(%d, %s, %s, %d, %s)', $revision->getID(), $relation_type, $relation['objectPHID'], $relation['sequence'], $relation['reasonPHID']); } $conn_w->openTransaction(); queryfx( $conn_w, 'DELETE FROM %T WHERE revisionID = %d AND relation = %s', DifferentialRevision::RELATIONSHIP_TABLE, $revision->getID(), $relation_type); if ($sql) { queryfx( $conn_w, 'INSERT INTO %T (revisionID, relation, objectPHID, sequence, reasonPHID) VALUES %Q', DifferentialRevision::RELATIONSHIP_TABLE, implode(', ', $sql)); } $conn_w->saveTransaction(); $revision->loadRelationships(); } private function createComment() { $revision_id = $this->revision->getID(); $comment = id(new DifferentialComment()) ->setAuthorPHID($this->getActorPHID()) ->setRevisionID($revision_id) ->setContent($this->getComments()) ->setAction(DifferentialAction::ACTION_UPDATE) ->setMetadata( array( DifferentialComment::METADATA_DIFF_ID => $this->getDiff()->getID(), )); if ($this->contentSource) { $comment->setContentSource($this->contentSource); } $comment->save(); return $comment; } 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 DifferentialAuxiliaryField())->loadAllWhere( 'revisionPHID = %s AND name IN (%Ls)', $revision->getPHID(), array_keys($aux_map)); $fields = mpull($fields, null, 'getName'); foreach ($aux_map as $key => $val) { $obj = idx($fields, $key); 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 DifferentialAuxiliaryField(); $obj->setRevisionPHID($revision->getPHID()); $obj->setName($key); } if ($obj->getValue() !== $val) { $obj->setValue($val); $obj->save(); } } } } private function willWriteRevision() { foreach ($this->auxiliaryFields as $aux_field) { $aux_field->willWriteRevision($this); } } private function didWriteRevision() { foreach ($this->auxiliaryFields as $aux_field) { $aux_field->didWriteRevision($this); } } /** * 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)); } } private function initializeNewRevision(DifferentialRevision $revision) { // These fields aren't nullable; set them to sensible defaults if they // haven't been configured. We're just doing this so we can generate an // ID for the revision if we don't have one already. $revision->setLineCount(0); if ($revision->getStatus() === null) { $revision->setStatus(ArcanistDifferentialRevisionStatus::NEEDS_REVIEW); } if ($revision->getTitle() === null) { $revision->setTitle('Untitled Revision'); } if ($revision->getAuthorPHID() === null) { $revision->setAuthorPHID($this->getActorPHID()); } if ($revision->getSummary() === null) { $revision->setSummary(''); } if ($revision->getTestPlan() === null) { $revision->setTestPlan(''); } } } diff --git a/src/applications/differential/field/specification/DifferentialFieldSpecification.php b/src/applications/differential/field/specification/DifferentialFieldSpecification.php index cce1c250f9..2f78967d7d 100644 --- a/src/applications/differential/field/specification/DifferentialFieldSpecification.php +++ b/src/applications/differential/field/specification/DifferentialFieldSpecification.php @@ -1,1001 +1,1015 @@ 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); } /** * 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; + } + /** * Hook for applying revision changes via the editor. Normally, you should * not implement this, but a number of builtin fields use the revision object * itself as storage. If you need to do something similar for whatever reason, * this method gives you an opportunity to interact with the editor or * revision before changes are saved (for example, you can write the field's * value into some property of the revision). * * @param DifferentialRevisionEditor Active editor which is applying changes * to the revision. * @return void * @task edit */ public function willWriteRevision(DifferentialRevisionEditor $editor) { return; } /** * Hook after an edit operation has completed. This allows you to update * link tables or do other write operations which should happen after the * revision is saved. Normally you don't need to implement this. * * * @param DifferentialRevisionEditor Active editor which has just applied * changes to the revision. * @return void * @task edit */ public function didWriteRevision(DifferentialRevisionEditor $editor) { return; } /* -( 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 'None'; } $links = array(); foreach ($user_phids as $user_phid) { $handle = $this->getHandle($user_phid); $links[] = $handle->renderLink(); } return implode(', ', $links); } /** * 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 E-mail Interface )------------------------------------- */ /** * Return plain text to render in e-mail messages. The text may span * multiple lines. * * @return int One of DifferentialMailPhase constants. * @return string|null Plain text, or null for no message. * * @task mail */ public function renderValueForMail($phase) { return null; } /* -( Extending the Conduit Interface )------------------------------------ */ /** * @task conduit */ public function shouldAppearOnConduitView() { return false; } /** * @task conduit */ public function getValueForConduit() { throw new DifferentialFieldSpecificationIncompleteException($this); } /** * @task conduit */ public function getKeyForConduit() { $key = $this->getStorageKey(); if ($key === null) { throw new DifferentialFieldSpecificationIncompleteException($this); } return $key; } /* -( 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); } /** * 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) { $value = array_unique(array_filter(preg_split('/[\s,]+/', $value))); if (!$value) { return array(); } $object_map = array(); $users = id(new PhabricatorUser())->loadAllWhere( '(username IN (%Ls))', $value); $user_map = mpull($users, 'getPHID', 'getUsername'); foreach ($user_map as $username => $phid) { // Usernames may have uppercase letters in them. Put both names in the // map so we can try the original case first, so that username *always* // works in weird edge cases where some other mailable object collides. $object_map[$username] = $phid; $object_map[strtolower($username)] = $phid; } if ($include_mailables) { $mailables = id(new PhabricatorMetaMTAMailingList())->loadAllWhere( '(email IN (%Ls)) OR (name IN (%Ls))', $value, $value); $object_map += mpull($mailables, 'getPHID', 'getName'); $object_map += mpull($mailables, 'getPHID', 'getEmail'); } $invalid = array(); $results = array(); foreach ($value as $name) { if (empty($object_map[$name])) { if (empty($object_map[strtolower($name)])) { $invalid[] = $name; } else { $results[] = $object_map[strtolower($name)]; } } else { $results[] = $object_map[$name]; } } if ($invalid && !$allow_partial) { $invalid = implode(', ', $invalid); $what = $include_mailables ? "users and mailing lists" : "users"; throw new DifferentialFieldParseException( "Commit message references nonexistent {$what}: {$invalid}."); } return array_unique($results); } /* -( 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]; } /** * 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/DifferentialSummaryFieldSpecification.php b/src/applications/differential/field/specification/DifferentialSummaryFieldSpecification.php index 20a11bfef7..59beb0f8c9 100644 --- a/src/applications/differential/field/specification/DifferentialSummaryFieldSpecification.php +++ b/src/applications/differential/field/specification/DifferentialSummaryFieldSpecification.php @@ -1,85 +1,89 @@ summary = (string)$this->getRevision()->getSummary(); } public function setValueFromRequest(AphrontRequest $request) { $this->summary = $request->getStr('summary'); return $this; } public function renderEditControl() { return id(new AphrontFormTextAreaControl()) ->setLabel('Summary') ->setName('summary') ->setValue($this->summary); } + public function shouldExtractMentions() { + return true; + } + public function willWriteRevision(DifferentialRevisionEditor $editor) { $this->getRevision()->setSummary($this->summary); } public function shouldAppearOnCommitMessage() { return true; } public function getCommitMessageKey() { return 'summary'; } public function setValueFromParsedCommitMessage($value) { $this->summary = (string)$value; return $this; } public function shouldOverwriteWhenCommitMessageIsEdited() { return true; } public function renderLabelForCommitMessage() { return 'Summary'; } public function renderValueForCommitMessage($is_edit) { return $this->summary; } public function parseValueFromCommitMessage($value) { return (string)$value; } public function renderValueForMail($phase) { if ($phase != DifferentialMailPhase::WELCOME) { return null; } if ($this->summary == '') { return null; } return $this->summary; } public function shouldAddToSearchIndex() { return true; } public function getValueForSearchIndex() { return $this->summary; } public function getKeyForSearchIndex() { return PhabricatorSearchField::FIELD_BODY; } } diff --git a/src/applications/differential/field/specification/DifferentialTestPlanFieldSpecification.php b/src/applications/differential/field/specification/DifferentialTestPlanFieldSpecification.php index e73a715f54..9f88204f05 100644 --- a/src/applications/differential/field/specification/DifferentialTestPlanFieldSpecification.php +++ b/src/applications/differential/field/specification/DifferentialTestPlanFieldSpecification.php @@ -1,124 +1,128 @@ plan = (string)$this->getRevision()->getTestPlan(); } public function setValueFromRequest(AphrontRequest $request) { $this->plan = $request->getStr('testplan'); $this->error = null; return $this; } public function renderEditControl() { if ($this->error === false) { if ($this->isRequired()) { $this->error = true; } else { $this->error = null; } } return id(new AphrontFormTextAreaControl()) ->setLabel('Test Plan') ->setName('testplan') ->setValue($this->plan) ->setError($this->error); } + public function shouldExtractMentions() { + return true; + } + public function willWriteRevision(DifferentialRevisionEditor $editor) { $this->getRevision()->setTestPlan($this->plan); } public function validateField() { if ($this->isRequired()) { if (!strlen($this->plan)) { $this->error = 'Required'; throw new DifferentialFieldValidationException( "You must provide a test plan."); } } } public function shouldAppearOnCommitMessage() { return PhabricatorEnv::getEnvConfig('differential.show-test-plan-field'); } public function getCommitMessageKey() { return 'testPlan'; } public function setValueFromParsedCommitMessage($value) { $this->plan = (string)$value; return $this; } public function shouldOverwriteWhenCommitMessageIsEdited() { return true; } public function renderLabelForCommitMessage() { return 'Test Plan'; } public function getSupportedCommitMessageLabels() { return array( 'Test Plan', 'Testplan', 'Tested', 'Tests', ); } public function renderValueForCommitMessage($is_edit) { return $this->plan; } public function parseValueFromCommitMessage($value) { return $value; } public function renderValueForMail($phase) { if ($phase != DifferentialMailPhase::WELCOME) { return null; } if ($this->plan == '') { return null; } return "TEST PLAN\n".preg_replace('/^/m', ' ', $this->plan); } public function shouldAddToSearchIndex() { return true; } public function getValueForSearchIndex() { return $this->plan; } public function getKeyForSearchIndex() { return 'tpln'; } private function isRequired() { return PhabricatorEnv::getEnvConfig('differential.require-test-plan-field'); } } diff --git a/src/applications/differential/field/specification/DifferentialTitleFieldSpecification.php b/src/applications/differential/field/specification/DifferentialTitleFieldSpecification.php index 9a785d07ab..6a1c6ce20c 100644 --- a/src/applications/differential/field/specification/DifferentialTitleFieldSpecification.php +++ b/src/applications/differential/field/specification/DifferentialTitleFieldSpecification.php @@ -1,94 +1,98 @@ title = $this->getRevision()->getTitle(); } public function setValueFromRequest(AphrontRequest $request) { $this->title = $request->getStr('title'); $this->error = null; return $this; } public function renderEditControl() { return id(new AphrontFormTextAreaControl()) ->setLabel('Title') ->setName('title') ->setHeight(AphrontFormTextAreaControl::HEIGHT_VERY_SHORT) ->setError($this->error) ->setValue($this->title); } + public function shouldExtractMentions() { + return true; + } + public function willWriteRevision(DifferentialRevisionEditor $editor) { $this->getRevision()->setTitle($this->title); } public function validateField() { if (!strlen($this->title)) { $this->error = 'Required'; throw new DifferentialFieldValidationException( "You must provide a title."); } } public function shouldAppearOnCommitMessage() { return true; } public function getCommitMessageKey() { return 'title'; } public function setValueFromParsedCommitMessage($value) { $this->title = $value; return $this; } public function shouldOverwriteWhenCommitMessageIsEdited() { return true; } public function renderLabelForCommitMessage() { return 'Title'; } public function renderValueForCommitMessage($is_edit) { return $this->title; } public function parseValueFromCommitMessage($value) { return preg_replace('/\s*\n\s*/', ' ', $value); } public function shouldAppearOnRevisionList() { return true; } public function renderHeaderForRevisionList() { return 'Revision'; } public function getColumnClassForRevisionList() { return 'wide pri'; } public function renderValueForRevisionList(DifferentialRevision $revision) { return phutil_render_tag( 'a', array( 'href' => '/D'.$revision->getID(), ), phutil_escape_html($revision->getTitle())); } }