@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.) hq.recaptime.dev/wiki/Phorge
phorge phabricator
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Build a rough transaction-level view of Feed

Summary:
Ref T13294. An install is interested in a way to easily answer audit-focused questions like "what edits were made to any Herald rule in Q1 2019?".

We can answer this kind of question with a more granular version of feed that focuses on being exhaustive rather than being human-readable.

This starts a rough version of it and deals with the two major tricky pieces: transactions are in a lot of different tables; and paging across them is not trivial.

To solve "lots of tables", we just query every table. There's a little bit of sleight-of-hand to get this working, but nothing too awful.

To solve "paging is hard", we order by "<dateCreated, phid>". The "phid" part of this order doesn't have much meaning, but it lets us put every transaction in a single, stable, global order and identify a place in that ordering given only one transaction PHID.

Test Plan: {F6463076}

Reviewers: amckinley

Reviewed By: amckinley

Maniphest Tasks: T13294

Differential Revision: https://secure.phabricator.com/D20531

+416 -34
+6
src/__phutil_library_map__.php
··· 3265 3265 'PhabricatorFeedStoryNotification' => 'applications/notification/storage/PhabricatorFeedStoryNotification.php', 3266 3266 'PhabricatorFeedStoryPublisher' => 'applications/feed/PhabricatorFeedStoryPublisher.php', 3267 3267 'PhabricatorFeedStoryReference' => 'applications/feed/storage/PhabricatorFeedStoryReference.php', 3268 + 'PhabricatorFeedTransactionListController' => 'applications/feed/controller/PhabricatorFeedTransactionListController.php', 3269 + 'PhabricatorFeedTransactionQuery' => 'applications/feed/query/PhabricatorFeedTransactionQuery.php', 3270 + 'PhabricatorFeedTransactionSearchEngine' => 'applications/feed/query/PhabricatorFeedTransactionSearchEngine.php', 3268 3271 'PhabricatorFerretEngine' => 'applications/search/ferret/PhabricatorFerretEngine.php', 3269 3272 'PhabricatorFerretEngineTestCase' => 'applications/search/ferret/__tests__/PhabricatorFerretEngineTestCase.php', 3270 3273 'PhabricatorFerretFulltextEngineExtension' => 'applications/search/engineextension/PhabricatorFerretFulltextEngineExtension.php', ··· 9330 9333 'PhabricatorFeedStoryNotification' => 'PhabricatorFeedDAO', 9331 9334 'PhabricatorFeedStoryPublisher' => 'Phobject', 9332 9335 'PhabricatorFeedStoryReference' => 'PhabricatorFeedDAO', 9336 + 'PhabricatorFeedTransactionListController' => 'PhabricatorFeedController', 9337 + 'PhabricatorFeedTransactionQuery' => 'PhabricatorCursorPagedPolicyAwareQuery', 9338 + 'PhabricatorFeedTransactionSearchEngine' => 'PhabricatorApplicationSearchEngine', 9333 9339 'PhabricatorFerretEngine' => 'Phobject', 9334 9340 'PhabricatorFerretEngineTestCase' => 'PhabricatorTestCase', 9335 9341 'PhabricatorFerretFulltextEngineExtension' => 'PhabricatorFulltextEngineExtension',
+4
src/applications/feed/application/PhabricatorFeedApplication.php
··· 31 31 '/feed/' => array( 32 32 '(?P<id>\d+)/' => 'PhabricatorFeedDetailController', 33 33 '(?:query/(?P<queryKey>[^/]+)/)?' => 'PhabricatorFeedListController', 34 + 'transactions/' => array( 35 + $this->getQueryRoutePattern() 36 + => 'PhabricatorFeedTransactionListController', 37 + ), 34 38 ), 35 39 ); 36 40 }
+2 -22
src/applications/feed/controller/PhabricatorFeedController.php
··· 1 1 <?php 2 2 3 - abstract class PhabricatorFeedController extends PhabricatorController { 4 - 5 - protected function buildSideNavView() { 6 - $user = $this->getRequest()->getUser(); 7 - 8 - $nav = new AphrontSideNavFilterView(); 9 - $nav->setBaseURI(new PhutilURI($this->getApplicationURI())); 10 - 11 - id(new PhabricatorFeedSearchEngine()) 12 - ->setViewer($user) 13 - ->addNavigationItems($nav->getMenu()); 14 - 15 - $nav->selectFilter(null); 16 - 17 - return $nav; 18 - } 19 - 20 - public function buildApplicationMenu() { 21 - return $this->buildSideNavView()->getMenu(); 22 - } 23 - 24 - } 3 + abstract class PhabricatorFeedController 4 + extends PhabricatorController {}
+14 -7
src/applications/feed/controller/PhabricatorFeedListController.php
··· 1 1 <?php 2 2 3 - final class PhabricatorFeedListController extends PhabricatorFeedController { 3 + final class PhabricatorFeedListController 4 + extends PhabricatorFeedController { 4 5 5 6 public function shouldAllowPublic() { 6 7 return true; 7 8 } 8 9 9 10 public function handleRequest(AphrontRequest $request) { 10 - $querykey = $request->getURIData('queryKey'); 11 + $navigation = array(); 12 + 13 + $navigation[] = id(new PHUIListItemView()) 14 + ->setType(PHUIListItemView::TYPE_LABEL) 15 + ->setName(pht('Transactions')); 11 16 12 - $controller = id(new PhabricatorApplicationSearchController()) 13 - ->setQueryKey($querykey) 14 - ->setSearchEngine(new PhabricatorFeedSearchEngine()) 15 - ->setNavigation($this->buildSideNavView()); 17 + $navigation[] = id(new PHUIListItemView()) 18 + ->setName(pht('Transaction Logs')) 19 + ->setHref($this->getApplicationURI('transactions/')); 16 20 17 - return $this->delegateToController($controller); 21 + return id(new PhabricatorFeedSearchEngine()) 22 + ->setController($this) 23 + ->setNavigationItems($navigation) 24 + ->buildResponse(); 18 25 } 19 26 20 27 }
+16
src/applications/feed/controller/PhabricatorFeedTransactionListController.php
··· 1 + <?php 2 + 3 + final class PhabricatorFeedTransactionListController 4 + extends PhabricatorFeedController { 5 + 6 + public function shouldAllowPublic() { 7 + return true; 8 + } 9 + 10 + public function handleRequest(AphrontRequest $request) { 11 + return id(new PhabricatorFeedTransactionSearchEngine()) 12 + ->setController($this) 13 + ->buildResponse(); 14 + } 15 + 16 + }
+178
src/applications/feed/query/PhabricatorFeedTransactionQuery.php
··· 1 + <?php 2 + 3 + final class PhabricatorFeedTransactionQuery 4 + extends PhabricatorCursorPagedPolicyAwareQuery { 5 + 6 + private $phids; 7 + private $createdMin; 8 + private $createdMax; 9 + 10 + public function withPHIDs(array $phids) { 11 + $this->phids = $phids; 12 + return $this; 13 + } 14 + 15 + public function withDateCreatedBetween($min, $max) { 16 + $this->createdMin = $min; 17 + $this->createdMax = $max; 18 + return $this; 19 + } 20 + 21 + protected function loadPage() { 22 + $queries = $this->newTransactionQueries(); 23 + 24 + $xactions = array(); 25 + 26 + if ($this->shouldLimitResults()) { 27 + $limit = $this->getRawResultLimit(); 28 + if (!$limit) { 29 + $limit = null; 30 + } 31 + } else { 32 + $limit = null; 33 + } 34 + 35 + // We're doing a bit of manual work to get paging working, because this 36 + // query aggregates the results of a large number of subqueries. 37 + 38 + // Overall, we're ordering transactions by "<dateCreated, phid>". Ordering 39 + // by PHID is not very meaningful, but we don't need the ordering to be 40 + // especially meaningful, just consistent. Using PHIDs is easy and does 41 + // everything we need it to technically. 42 + 43 + // To actually configure paging, if we have an external cursor, we load 44 + // the internal cursor first. Then we pass it to each subquery and the 45 + // subqueries pretend they just loaded a page where it was the last object. 46 + // This configures their queries properly and we can aggregate a cohesive 47 + // set of results by combining all the queries. 48 + 49 + $cursor = $this->getExternalCursorString(); 50 + if ($cursor !== null) { 51 + $cursor_object = $this->newInternalCursorFromExternalCursor($cursor); 52 + } else { 53 + $cursor_object = null; 54 + } 55 + 56 + $is_reversed = $this->getIsQueryOrderReversed(); 57 + 58 + $created_min = $this->createdMin; 59 + $created_max = $this->createdMax; 60 + 61 + $xaction_phids = $this->phids; 62 + 63 + foreach ($queries as $query) { 64 + $query->withDateCreatedBetween($created_min, $created_max); 65 + 66 + if ($xaction_phids !== null) { 67 + $query->withPHIDs($xaction_phids); 68 + } 69 + 70 + if ($limit !== null) { 71 + $query->setLimit($limit); 72 + } 73 + 74 + if ($cursor_object !== null) { 75 + $query 76 + ->setAggregatePagingCursor($cursor_object) 77 + ->setIsQueryOrderReversed($is_reversed); 78 + } 79 + 80 + $query->setOrder('global'); 81 + 82 + $query_xactions = $query->execute(); 83 + foreach ($query_xactions as $query_xaction) { 84 + $xactions[] = $query_xaction; 85 + } 86 + 87 + $xactions = msortv($xactions, 'newGlobalSortVector'); 88 + if ($is_reversed) { 89 + $xactions = array_reverse($xactions); 90 + } 91 + 92 + if ($limit !== null) { 93 + $xactions = array_slice($xactions, 0, $limit); 94 + 95 + // If we've found enough transactions to fill up the entire requested 96 + // page size, we can narrow the search window: transactions after the 97 + // last transaction we've found so far can't possibly be part of the 98 + // result set. 99 + 100 + if (count($xactions) === $limit) { 101 + $last_date = last($xactions)->getDateCreated(); 102 + if ($is_reversed) { 103 + if ($created_max === null) { 104 + $created_max = $last_date; 105 + } else { 106 + $created_max = min($created_max, $last_date); 107 + } 108 + } else { 109 + if ($created_min === null) { 110 + $created_min = $last_date; 111 + } else { 112 + $created_min = max($created_min, $last_date); 113 + } 114 + } 115 + } 116 + } 117 + } 118 + 119 + return $xactions; 120 + } 121 + 122 + public function getQueryApplicationClass() { 123 + return 'PhabricatorFeedApplication'; 124 + } 125 + 126 + private function newTransactionQueries() { 127 + $viewer = $this->getViewer(); 128 + 129 + $queries = id(new PhutilClassMapQuery()) 130 + ->setAncestorClass('PhabricatorApplicationTransactionQuery') 131 + ->execute(); 132 + 133 + $type_map = array(); 134 + 135 + // If we're querying for specific transaction PHIDs, we only need to 136 + // consider queries which may load transactions with subtypes present 137 + // in the list. 138 + 139 + // For example, if we're loading Maniphest Task transaction PHIDs, we know 140 + // we only have to look at Maniphest Task transactions, since other types 141 + // of objects will never have the right transaction PHIDs. 142 + 143 + $xaction_phids = $this->phids; 144 + if ($xaction_phids) { 145 + foreach ($xaction_phids as $xaction_phid) { 146 + $type_map[phid_get_subtype($xaction_phid)] = true; 147 + } 148 + } 149 + 150 + $results = array(); 151 + foreach ($queries as $query) { 152 + if ($type_map) { 153 + $type = $query->getTemplateApplicationTransaction() 154 + ->getApplicationTransactionType(); 155 + if (!isset($type_map[$type])) { 156 + continue; 157 + } 158 + } 159 + 160 + $results[] = id(clone $query) 161 + ->setViewer($viewer) 162 + ->setParentQuery($this); 163 + } 164 + 165 + return $results; 166 + } 167 + 168 + protected function newExternalCursorStringForResult($object) { 169 + return (string)$object->getPHID(); 170 + } 171 + 172 + protected function applyExternalCursorConstraintsToQuery( 173 + PhabricatorCursorPagedPolicyAwareQuery $subquery, 174 + $cursor) { 175 + $subquery->withPHIDs(array($cursor)); 176 + } 177 + 178 + }
+113
src/applications/feed/query/PhabricatorFeedTransactionSearchEngine.php
··· 1 + <?php 2 + 3 + final class PhabricatorFeedTransactionSearchEngine 4 + extends PhabricatorApplicationSearchEngine { 5 + 6 + public function getResultTypeDescription() { 7 + return pht('Transactions'); 8 + } 9 + 10 + public function getApplicationClassName() { 11 + return 'PhabricatorFeedApplication'; 12 + } 13 + 14 + public function newQuery() { 15 + return new PhabricatorFeedTransactionQuery(); 16 + } 17 + 18 + protected function buildCustomSearchFields() { 19 + return array(); 20 + } 21 + 22 + protected function buildQueryFromParameters(array $map) { 23 + $query = $this->newQuery(); 24 + 25 + return $query; 26 + } 27 + 28 + protected function getURI($path) { 29 + return '/feed/transactions/'.$path; 30 + } 31 + 32 + protected function getBuiltinQueryNames() { 33 + $names = array( 34 + 'all' => pht('All Transactions'), 35 + ); 36 + 37 + return $names; 38 + } 39 + 40 + public function buildSavedQueryFromBuiltin($query_key) { 41 + $query = $this->newSavedQuery() 42 + ->setQueryKey($query_key); 43 + 44 + switch ($query_key) { 45 + case 'all': 46 + return $query; 47 + } 48 + 49 + return parent::buildSavedQueryFromBuiltin($query_key); 50 + } 51 + 52 + protected function renderResultList( 53 + array $objects, 54 + PhabricatorSavedQuery $query, 55 + array $handles) { 56 + assert_instances_of($objects, 'PhabricatorApplicationTransaction'); 57 + 58 + $viewer = $this->requireViewer(); 59 + 60 + $handle_phids = array(); 61 + foreach ($objects as $object) { 62 + $author_phid = $object->getAuthorPHID(); 63 + if ($author_phid !== null) { 64 + $handle_phids[] = $author_phid; 65 + } 66 + $object_phid = $object->getObjectPHID(); 67 + if ($object_phid !== null) { 68 + $handle_phids[] = $object_phid; 69 + } 70 + } 71 + 72 + $handles = $viewer->loadHandles($handle_phids); 73 + 74 + $rows = array(); 75 + foreach ($objects as $object) { 76 + $author_phid = $object->getAuthorPHID(); 77 + $object_phid = $object->getObjectPHID(); 78 + 79 + try { 80 + $title = $object->getTitle(); 81 + } catch (Exception $ex) { 82 + $title = null; 83 + } 84 + 85 + $rows[] = array( 86 + $handles[$author_phid]->renderLink(), 87 + $handles[$object_phid]->renderLink(), 88 + AphrontTableView::renderSingleDisplayLine($title), 89 + phabricator_datetime($object->getDateCreated(), $viewer), 90 + ); 91 + } 92 + 93 + $table = id(new AphrontTableView($rows)) 94 + ->setHeaders( 95 + array( 96 + pht('Actor'), 97 + pht('Object'), 98 + pht('Transaction'), 99 + pht('Date'), 100 + )) 101 + ->setColumnClasses( 102 + array( 103 + null, 104 + null, 105 + 'wide', 106 + 'right', 107 + )); 108 + 109 + return id(new PhabricatorApplicationSearchResultView()) 110 + ->setTable($table); 111 + } 112 + 113 + }
+73
src/applications/transactions/query/PhabricatorApplicationTransactionQuery.php
··· 9 9 private $authorPHIDs; 10 10 private $transactionTypes; 11 11 private $withComments; 12 + private $createdMin; 13 + private $createdMax; 14 + private $aggregatePagingCursor; 12 15 13 16 private $needComments = true; 14 17 private $needHandles = true; ··· 66 69 return $this; 67 70 } 68 71 72 + public function withDateCreatedBetween($min, $max) { 73 + $this->createdMin = $min; 74 + $this->createdMax = $max; 75 + return $this; 76 + } 77 + 69 78 public function needComments($need) { 70 79 $this->needComments = $need; 71 80 return $this; ··· 74 83 public function needHandles($need) { 75 84 $this->needHandles = $need; 76 85 return $this; 86 + } 87 + 88 + public function setAggregatePagingCursor(PhabricatorQueryCursor $cursor) { 89 + $this->aggregatePagingCursor = $cursor; 90 + return $this; 91 + } 92 + 93 + public function getAggregatePagingCursor() { 94 + return $this->aggregatePagingCursor; 95 + } 96 + 97 + protected function willExecute() { 98 + $cursor_object = $this->getAggregatePagingCursor(); 99 + if ($cursor_object) { 100 + $this->nextPage(array($cursor_object->getObject())); 101 + } 77 102 } 78 103 79 104 protected function loadPage() { ··· 206 231 } 207 232 } 208 233 234 + if ($this->createdMin !== null) { 235 + $where[] = qsprintf( 236 + $conn, 237 + 'x.dateCreated >= %d', 238 + $this->createdMin); 239 + } 240 + 241 + if ($this->createdMax !== null) { 242 + $where[] = qsprintf( 243 + $conn, 244 + 'x.dateCreated <= %d', 245 + $this->createdMax); 246 + } 247 + 209 248 return $where; 210 249 } 211 250 ··· 261 300 protected function getPrimaryTableAlias() { 262 301 return 'x'; 263 302 } 303 + 304 + protected function newPagingMapFromPartialObject($object) { 305 + return parent::newPagingMapFromPartialObject($object) + array( 306 + 'created' => $object->getDateCreated(), 307 + 'phid' => $object->getPHID(), 308 + ); 309 + } 310 + 311 + public function getBuiltinOrders() { 312 + return parent::getBuiltinOrders() + array( 313 + 'global' => array( 314 + 'vector' => array('created', 'phid'), 315 + 'name' => pht('Global'), 316 + ), 317 + ); 318 + } 319 + 320 + public function getOrderableColumns() { 321 + return parent::getOrderableColumns() + array( 322 + 'created' => array( 323 + 'table' => 'x', 324 + 'column' => 'dateCreated', 325 + 'type' => 'int', 326 + ), 327 + 'phid' => array( 328 + 'table' => 'x', 329 + 'column' => 'phid', 330 + 'type' => 'string', 331 + 'reverse' => true, 332 + 'unique' => true, 333 + ), 334 + ); 335 + } 336 + 264 337 265 338 }
+6
src/applications/transactions/storage/PhabricatorApplicationTransaction.php
··· 1711 1711 return array($done, $undone); 1712 1712 } 1713 1713 1714 + public function newGlobalSortVector() { 1715 + return id(new PhutilSortVector()) 1716 + ->addInt(-$this->getDateCreated()) 1717 + ->addString($this->getPHID()); 1718 + } 1719 + 1714 1720 1715 1721 /* -( PhabricatorPolicyInterface Implementation )-------------------------- */ 1716 1722
+4 -5
src/infrastructure/query/policy/PhabricatorCursorPagedPolicyAwareQuery.php
··· 104 104 } 105 105 106 106 // Now that we made sure the viewer can actually see the object the 107 - // external cursor identifies, return the internal cursor the query 107 + // external cursor identifies, return the internal cursor the query 108 108 // generated as a side effect while loading the object. 109 109 return $query->getInternalCursorObject(); 110 110 } ··· 134 134 ); 135 135 } 136 136 137 - 138 137 final private function getExternalCursorStringForResult($object) { 139 138 $cursor = $this->newExternalCursorStringForResult($object); 140 139 ··· 150 149 return $cursor; 151 150 } 152 151 153 - final private function getExternalCursorString() { 152 + final protected function getExternalCursorString() { 154 153 return $this->externalCursorString; 155 154 } 156 155 ··· 159 158 return $this; 160 159 } 161 160 162 - final private function getIsQueryOrderReversed() { 161 + final protected function getIsQueryOrderReversed() { 163 162 return $this->isQueryOrderReversed; 164 163 } 165 164 166 - final private function setIsQueryOrderReversed($is_reversed) { 165 + final protected function setIsQueryOrderReversed($is_reversed) { 167 166 $this->isQueryOrderReversed = $is_reversed; 168 167 return $this; 169 168 }