diff --git a/src/applications/diffusion/query/DiffusionCommitHintQuery.php b/src/applications/diffusion/query/DiffusionCommitHintQuery.php index 28ae1ed70..bfd804513 100644 --- a/src/applications/diffusion/query/DiffusionCommitHintQuery.php +++ b/src/applications/diffusion/query/DiffusionCommitHintQuery.php @@ -1,64 +1,116 @@ <?php final class DiffusionCommitHintQuery extends PhabricatorCursorPagedPolicyAwareQuery { private $ids; private $repositoryPHIDs; private $oldCommitIdentifiers; + private $commits; + private $commitMap; + public function withIDs(array $ids) { $this->ids = $ids; return $this; } public function withRepositoryPHIDs(array $phids) { $this->repositoryPHIDs = $phids; return $this; } public function withOldCommitIdentifiers(array $identifiers) { $this->oldCommitIdentifiers = $identifiers; return $this; } + public function withCommits(array $commits) { + assert_instances_of($commits, 'PhabricatorRepositoryCommit'); + + $repository_phids = array(); + foreach ($commits as $commit) { + $repository_phids[] = $commit->getRepository()->getPHID(); + } + + $this->repositoryPHIDs = $repository_phids; + $this->oldCommitIdentifiers = mpull($commits, 'getCommitIdentifier'); + $this->commits = $commits; + + return $this; + } + + public function getCommitMap() { + if ($this->commitMap === null) { + throw new PhutilInvalidStateException('execute'); + } + + return $this->commitMap; + } + public function newResultObject() { return new PhabricatorRepositoryCommitHint(); } + protected function willExecute() { + $this->commitMap = array(); + } + protected function loadPage() { return $this->loadStandardPage($this->newResultObject()); } protected function buildWhereClauseParts(AphrontDatabaseConnection $conn) { $where = parent::buildWhereClauseParts($conn); if ($this->ids !== null) { $where[] = qsprintf( $conn, 'id IN (%Ld)', $this->ids); } if ($this->repositoryPHIDs !== null) { $where[] = qsprintf( $conn, 'repositoryPHID IN (%Ls)', $this->repositoryPHIDs); } if ($this->oldCommitIdentifiers !== null) { $where[] = qsprintf( $conn, 'oldCommitIdentifier IN (%Ls)', $this->oldCommitIdentifiers); } return $where; } + protected function didFilterPage(array $hints) { + if ($this->commits) { + $map = array(); + foreach ($this->commits as $commit) { + $repository_phid = $commit->getRepository()->getPHID(); + $identifier = $commit->getCommitIdentifier(); + $map[$repository_phid][$identifier] = $commit->getPHID(); + } + + foreach ($hints as $hint) { + $repository_phid = $hint->getRepositoryPHID(); + $identifier = $hint->getOldCommitIdentifier(); + if (isset($map[$repository_phid][$identifier])) { + $commit_phid = $map[$repository_phid][$identifier]; + $this->commitMap[$commit_phid] = $hint; + } + } + } + + return $hints; + } + public function getQueryApplicationClass() { return 'PhabricatorDiffusionApplication'; } } diff --git a/src/applications/diffusion/remarkup/DiffusionCommitRemarkupRule.php b/src/applications/diffusion/remarkup/DiffusionCommitRemarkupRule.php index 2722e9d81..4d2d130c3 100644 --- a/src/applications/diffusion/remarkup/DiffusionCommitRemarkupRule.php +++ b/src/applications/diffusion/remarkup/DiffusionCommitRemarkupRule.php @@ -1,28 +1,47 @@ <?php final class DiffusionCommitRemarkupRule extends PhabricatorObjectRemarkupRule { protected function getObjectNamePrefix() { return ''; } protected function getObjectNamePrefixBeginsWithWordCharacter() { return true; } protected function getObjectIDPattern() { return PhabricatorRepositoryCommitPHIDType::getCommitObjectNamePattern(); } + protected function getObjectNameText( + $object, + PhabricatorObjectHandle $handle, + $id) { + + // If this commit is unreachable, return the handle name instead of the + // normal text because it may be able to tell the user that the commit + // was rewritten and where to find the new one. + + // By default, we try to preserve what the user actually typed as + // faithfully as possible, but if they're referencing a deleted commit + // it's more valuable to try to pick up any rewrite. See T11522. + if ($object->isUnreachable()) { + return $handle->getName(); + } + + return parent::getObjectNameText($object, $handle, $id); + } + protected function loadObjects(array $ids) { $viewer = $this->getEngine()->getConfig('viewer'); $query = id(new DiffusionCommitQuery()) ->setViewer($viewer) ->withIdentifiers($ids); $query->execute(); return $query->getIdentifierMap(); } } diff --git a/src/applications/repository/phid/PhabricatorRepositoryCommitPHIDType.php b/src/applications/repository/phid/PhabricatorRepositoryCommitPHIDType.php index 5d3ce3c96..df84f2dcf 100644 --- a/src/applications/repository/phid/PhabricatorRepositoryCommitPHIDType.php +++ b/src/applications/repository/phid/PhabricatorRepositoryCommitPHIDType.php @@ -1,86 +1,117 @@ <?php final class PhabricatorRepositoryCommitPHIDType extends PhabricatorPHIDType { const TYPECONST = 'CMIT'; public function getTypeName() { return pht('Diffusion Commit'); } public function newObject() { return new PhabricatorRepositoryCommit(); } public function getPHIDTypeApplicationClass() { return 'PhabricatorDiffusionApplication'; } protected function buildQueryForObjects( PhabricatorObjectQuery $query, array $phids) { return id(new DiffusionCommitQuery()) ->withPHIDs($phids); } public function loadHandles( PhabricatorHandleQuery $query, array $handles, array $objects) { + $unreachable = array(); + foreach ($handles as $phid => $handle) { + $commit = $objects[$phid]; + if ($commit->isUnreachable()) { + $unreachable[$phid] = $commit; + } + } + + if ($unreachable) { + $query = id(new DiffusionCommitHintQuery()) + ->setViewer($query->getViewer()) + ->withCommits($unreachable); + + $query->execute(); + + $hints = $query->getCommitMap(); + } else { + $hints = array(); + } + foreach ($handles as $phid => $handle) { $commit = $objects[$phid]; $repository = $commit->getRepository(); $commit_identifier = $commit->getCommitIdentifier(); $name = $repository->formatCommitName($commit_identifier); + + if ($commit->isUnreachable()) { + $handle->setStatus(PhabricatorObjectHandle::STATUS_CLOSED); + + // If we have a hint about this commit being rewritten, add the + // rewrite target to the handle name. This reduces the chance users + // will be caught offguard by the rewrite. + $hint = idx($hints, $phid); + if ($hint && $hint->isRewritten()) { + $new_name = $hint->getNewCommitIdentifier(); + $new_name = $repository->formatCommitName($new_name); + $name = pht("%s \xE2\x99\xBB %s", $name, $new_name); + } + } + $summary = $commit->getSummary(); if (strlen($summary)) { $full_name = $name.': '.$summary; } else { $full_name = $name; } $handle->setName($name); $handle->setFullName($full_name); $handle->setURI($commit->getURI()); $handle->setTimestamp($commit->getEpoch()); - - if ($commit->isUnreachable()) { - $handle->setStatus(PhabricatorObjectHandle::STATUS_CLOSED); - } } } public static function getCommitObjectNamePattern() { $min_unqualified = PhabricatorRepository::MINIMUM_UNQUALIFIED_HASH; $min_qualified = PhabricatorRepository::MINIMUM_QUALIFIED_HASH; return '(?:r[A-Z]+:?|R[0-9]+:)[1-9]\d*'. '|'. '(?:r[A-Z]+:?|R[0-9]+:)[a-f0-9]{'.$min_qualified.',40}'. '|'. '[a-f0-9]{'.$min_unqualified.',40}'; } public function canLoadNamedObject($name) { $pattern = self::getCommitObjectNamePattern(); return preg_match('(^'.$pattern.'$)', $name); } public function loadNamedObjects( PhabricatorObjectQuery $query, array $names) { $query = id(new DiffusionCommitQuery()) ->setViewer($query->getViewer()) ->withIdentifiers($names); $query->execute(); return $query->getIdentifierMap(); } } diff --git a/src/infrastructure/markup/rule/PhabricatorObjectRemarkupRule.php b/src/infrastructure/markup/rule/PhabricatorObjectRemarkupRule.php index d5addbc55..35c0ecfad 100644 --- a/src/infrastructure/markup/rule/PhabricatorObjectRemarkupRule.php +++ b/src/infrastructure/markup/rule/PhabricatorObjectRemarkupRule.php @@ -1,383 +1,390 @@ <?php abstract class PhabricatorObjectRemarkupRule extends PhutilRemarkupRule { const KEY_RULE_OBJECT = 'rule.object'; const KEY_MENTIONED_OBJECTS = 'rule.object.mentioned'; abstract protected function getObjectNamePrefix(); abstract protected function loadObjects(array $ids); public function getPriority() { return 450.0; } protected function getObjectNamePrefixBeginsWithWordCharacter() { $prefix = $this->getObjectNamePrefix(); return preg_match('/^\w/', $prefix); } protected function getObjectIDPattern() { return '[1-9]\d*'; } protected function shouldMarkupObject(array $params) { return true; } + protected function getObjectNameText( + $object, + PhabricatorObjectHandle $handle, + $id) { + return $this->getObjectNamePrefix().$id; + } + protected function loadHandles(array $objects) { $phids = mpull($objects, 'getPHID'); $viewer = $this->getEngine()->getConfig('viewer'); $handles = $viewer->loadHandles($phids); $handles = iterator_to_array($handles); $result = array(); foreach ($objects as $id => $object) { $result[$id] = $handles[$object->getPHID()]; } return $result; } protected function getObjectHref( $object, PhabricatorObjectHandle $handle, $id) { $uri = $handle->getURI(); if ($this->getEngine()->getConfig('uri.full')) { $uri = PhabricatorEnv::getURI($uri); } return $uri; } protected function renderObjectRefForAnyMedia( $object, PhabricatorObjectHandle $handle, $anchor, $id) { $href = $this->getObjectHref($object, $handle, $id); - $text = $this->getObjectNamePrefix().$id; + $text = $this->getObjectNameText($object, $handle, $id); if ($anchor) { $href = $href.'#'.$anchor; $text = $text.'#'.$anchor; } if ($this->getEngine()->isTextMode()) { return PhabricatorEnv::getProductionURI($href); } else if ($this->getEngine()->isHTMLMailMode()) { $href = PhabricatorEnv::getProductionURI($href); return $this->renderObjectTagForMail($text, $href, $handle); } return $this->renderObjectRef($object, $handle, $anchor, $id); } protected function renderObjectRef( $object, PhabricatorObjectHandle $handle, $anchor, $id) { $href = $this->getObjectHref($object, $handle, $id); - $text = $this->getObjectNamePrefix().$id; + $text = $this->getObjectNameText($object, $handle, $id); $status_closed = PhabricatorObjectHandle::STATUS_CLOSED; if ($anchor) { $href = $href.'#'.$anchor; $text = $text.'#'.$anchor; } $attr = array( 'phid' => $handle->getPHID(), 'closed' => ($handle->getStatus() == $status_closed), ); return $this->renderHovertag($text, $href, $attr); } protected function renderObjectEmbedForAnyMedia( $object, PhabricatorObjectHandle $handle, $options) { $name = $handle->getFullName(); $href = $handle->getURI(); if ($this->getEngine()->isTextMode()) { return $name.' <'.PhabricatorEnv::getProductionURI($href).'>'; } else if ($this->getEngine()->isHTMLMailMode()) { $href = PhabricatorEnv::getProductionURI($href); return $this->renderObjectTagForMail($name, $href, $handle); } return $this->renderObjectEmbed($object, $handle, $options); } protected function renderObjectEmbed( $object, PhabricatorObjectHandle $handle, $options) { $name = $handle->getFullName(); $href = $handle->getURI(); $status_closed = PhabricatorObjectHandle::STATUS_CLOSED; $attr = array( 'phid' => $handle->getPHID(), 'closed' => ($handle->getStatus() == $status_closed), ); return $this->renderHovertag($name, $href, $attr); } protected function renderObjectTagForMail( $text, $href, PhabricatorObjectHandle $handle) { $status_closed = PhabricatorObjectHandle::STATUS_CLOSED; $strikethrough = $handle->getStatus() == $status_closed ? 'text-decoration: line-through;' : 'text-decoration: none;'; return phutil_tag( 'a', array( 'href' => $href, 'style' => 'background-color: #e7e7e7; border-color: #e7e7e7; border-radius: 3px; padding: 0 4px; font-weight: bold; color: black;' .$strikethrough, ), $text); } protected function renderHovertag($name, $href, array $attr = array()) { return id(new PHUITagView()) ->setName($name) ->setHref($href) ->setType(PHUITagView::TYPE_OBJECT) ->setPHID(idx($attr, 'phid')) ->setClosed(idx($attr, 'closed')) ->render(); } public function apply($text) { $text = preg_replace_callback( $this->getObjectEmbedPattern(), array($this, 'markupObjectEmbed'), $text); $text = preg_replace_callback( $this->getObjectReferencePattern(), array($this, 'markupObjectReference'), $text); return $text; } private function getObjectEmbedPattern() { $prefix = $this->getObjectNamePrefix(); $prefix = preg_quote($prefix); $id = $this->getObjectIDPattern(); return '(\B{'.$prefix.'('.$id.')([,\s](?:[^}\\\\]|\\\\.)*)?}\B)u'; } private function getObjectReferencePattern() { $prefix = $this->getObjectNamePrefix(); $prefix = preg_quote($prefix); $id = $this->getObjectIDPattern(); // If the prefix starts with a word character (like "D"), we want to // require a word boundary so that we don't match "XD1" as "D1". If the // prefix does not start with a word character, we want to require no word // boundary for the same reasons. Test if the prefix starts with a word // character. if ($this->getObjectNamePrefixBeginsWithWordCharacter()) { $boundary = '\\b'; } else { $boundary = '\\B'; } // The "(?<![#@-])" prevents us from linking "#abcdef" or similar, and // "ABC-T1" (see T5714), and from matching "@T1" as a task (it is a user) // (see T9479). // The "\b" allows us to link "(abcdef)" or similar without linking things // in the middle of words. return '((?<![#@-])'.$boundary.$prefix.'('.$id.')(?:#([-\w\d]+))?(?!\w))u'; } /** * Extract matched object references from a block of text. * * This is intended to make it easy to write unit tests for object remarkup * rules. Production code is not normally expected to call this method. * * @param string Text to match rules against. * @return wild Matches, suitable for writing unit tests against. */ public function extractReferences($text) { $embed_matches = null; preg_match_all( $this->getObjectEmbedPattern(), $text, $embed_matches, PREG_OFFSET_CAPTURE | PREG_SET_ORDER); $ref_matches = null; preg_match_all( $this->getObjectReferencePattern(), $text, $ref_matches, PREG_OFFSET_CAPTURE | PREG_SET_ORDER); $results = array(); $sets = array( 'embed' => $embed_matches, 'ref' => $ref_matches, ); foreach ($sets as $type => $matches) { $formatted = array(); foreach ($matches as $match) { $format = array( 'offset' => $match[1][1], 'id' => $match[1][0], ); if (isset($match[2][0])) { $format['tail'] = $match[2][0]; } $formatted[] = $format; } $results[$type] = $formatted; } return $results; } public function markupObjectEmbed(array $matches) { if (!$this->isFlatText($matches[0])) { return $matches[0]; } return $this->markupObject(array( 'type' => 'embed', 'id' => $matches[1], 'options' => idx($matches, 2), 'original' => $matches[0], )); } public function markupObjectReference(array $matches) { if (!$this->isFlatText($matches[0])) { return $matches[0]; } return $this->markupObject(array( 'type' => 'ref', 'id' => $matches[1], 'anchor' => idx($matches, 2), 'original' => $matches[0], )); } private function markupObject(array $params) { if (!$this->shouldMarkupObject($params)) { return $params['original']; } $regex = trim( PhabricatorEnv::getEnvConfig('remarkup.ignored-object-names')); if ($regex && preg_match($regex, $params['original'])) { return $params['original']; } $engine = $this->getEngine(); $token = $engine->storeText('x'); $metadata_key = self::KEY_RULE_OBJECT.'.'.$this->getObjectNamePrefix(); $metadata = $engine->getTextMetadata($metadata_key, array()); $metadata[] = array( 'token' => $token, ) + $params; $engine->setTextMetadata($metadata_key, $metadata); return $token; } public function didMarkupText() { $engine = $this->getEngine(); $metadata_key = self::KEY_RULE_OBJECT.'.'.$this->getObjectNamePrefix(); $metadata = $engine->getTextMetadata($metadata_key, array()); if (!$metadata) { return; } $ids = ipull($metadata, 'id'); $objects = $this->loadObjects($ids); // For objects that are invalid or which the user can't see, just render // the original text. // TODO: We should probably distinguish between these cases and render a // "you can't see this" state for nonvisible objects. foreach ($metadata as $key => $spec) { if (empty($objects[$spec['id']])) { $engine->overwriteStoredText( $spec['token'], $spec['original']); unset($metadata[$key]); } } $phids = $engine->getTextMetadata(self::KEY_MENTIONED_OBJECTS, array()); foreach ($objects as $object) { $phids[$object->getPHID()] = $object->getPHID(); } $engine->setTextMetadata(self::KEY_MENTIONED_OBJECTS, $phids); $handles = $this->loadHandles($objects); foreach ($metadata as $key => $spec) { $handle = $handles[$spec['id']]; $object = $objects[$spec['id']]; switch ($spec['type']) { case 'ref': $view = $this->renderObjectRefForAnyMedia( $object, $handle, $spec['anchor'], $spec['id']); break; case 'embed': $spec['options'] = $this->assertFlatText($spec['options']); $view = $this->renderObjectEmbedForAnyMedia( $object, $handle, $spec['options']); break; } $engine->overwriteStoredText($spec['token'], $view); } $engine->setTextMetadata($metadata_key, array()); } }