1
0
Fork 0
mirror of https://we.phorge.it/source/phorge.git synced 2024-11-26 16:52:41 +01:00

Support basic notification aggregation

Summary: This is both only partially complete (supports Maniphest only) and somewhat overcomplicated (includes support for applying similar algorithms to Feed), but provides runtime aggregation of notifications.

Test Plan: {F13502}

Reviewers: btrahan, jungejason

Reviewed By: btrahan

CC: aran

Differential Revision: https://secure.phabricator.com/D2884
This commit is contained in:
epriestley 2012-07-01 11:08:59 -07:00
parent 126f8e7240
commit fc45398ba9
6 changed files with 291 additions and 1 deletions

View file

@ -638,11 +638,13 @@ phutil_register_library_map(array(
'PhabricatorFeedPublicStreamController' => 'applications/feed/controller/PhabricatorFeedPublicStreamController.php', 'PhabricatorFeedPublicStreamController' => 'applications/feed/controller/PhabricatorFeedPublicStreamController.php',
'PhabricatorFeedQuery' => 'applications/feed/PhabricatorFeedQuery.php', 'PhabricatorFeedQuery' => 'applications/feed/PhabricatorFeedQuery.php',
'PhabricatorFeedStory' => 'applications/feed/story/PhabricatorFeedStory.php', 'PhabricatorFeedStory' => 'applications/feed/story/PhabricatorFeedStory.php',
'PhabricatorFeedStoryAggregate' => 'applications/feed/story/PhabricatorFeedStoryAggregate.php',
'PhabricatorFeedStoryAudit' => 'applications/feed/story/PhabricatorFeedStoryAudit.php', 'PhabricatorFeedStoryAudit' => 'applications/feed/story/PhabricatorFeedStoryAudit.php',
'PhabricatorFeedStoryCommit' => 'applications/feed/story/PhabricatorFeedStoryCommit.php', 'PhabricatorFeedStoryCommit' => 'applications/feed/story/PhabricatorFeedStoryCommit.php',
'PhabricatorFeedStoryData' => 'applications/feed/storage/PhabricatorFeedStoryData.php', 'PhabricatorFeedStoryData' => 'applications/feed/storage/PhabricatorFeedStoryData.php',
'PhabricatorFeedStoryDifferential' => 'applications/feed/story/PhabricatorFeedStoryDifferential.php', 'PhabricatorFeedStoryDifferential' => 'applications/feed/story/PhabricatorFeedStoryDifferential.php',
'PhabricatorFeedStoryManiphest' => 'applications/feed/story/PhabricatorFeedStoryManiphest.php', 'PhabricatorFeedStoryManiphest' => 'applications/feed/story/PhabricatorFeedStoryManiphest.php',
'PhabricatorFeedStoryManiphestAggregate' => 'applications/feed/story/PhabricatorFeedStoryManiphestAggregate.php',
'PhabricatorFeedStoryNotification' => 'applications/notification/storage/PhabricatorFeedStoryNotification.php', 'PhabricatorFeedStoryNotification' => 'applications/notification/storage/PhabricatorFeedStoryNotification.php',
'PhabricatorFeedStoryPhriction' => 'applications/feed/story/PhabricatorFeedStoryPhriction.php', 'PhabricatorFeedStoryPhriction' => 'applications/feed/story/PhabricatorFeedStoryPhriction.php',
'PhabricatorFeedStoryProject' => 'applications/feed/story/PhabricatorFeedStoryProject.php', 'PhabricatorFeedStoryProject' => 'applications/feed/story/PhabricatorFeedStoryProject.php',
@ -1646,11 +1648,13 @@ phutil_register_library_map(array(
'PhabricatorFeedController' => 'PhabricatorController', 'PhabricatorFeedController' => 'PhabricatorController',
'PhabricatorFeedDAO' => 'PhabricatorLiskDAO', 'PhabricatorFeedDAO' => 'PhabricatorLiskDAO',
'PhabricatorFeedPublicStreamController' => 'PhabricatorFeedController', 'PhabricatorFeedPublicStreamController' => 'PhabricatorFeedController',
'PhabricatorFeedStoryAggregate' => 'PhabricatorFeedStory',
'PhabricatorFeedStoryAudit' => 'PhabricatorFeedStory', 'PhabricatorFeedStoryAudit' => 'PhabricatorFeedStory',
'PhabricatorFeedStoryCommit' => 'PhabricatorFeedStory', 'PhabricatorFeedStoryCommit' => 'PhabricatorFeedStory',
'PhabricatorFeedStoryData' => 'PhabricatorFeedDAO', 'PhabricatorFeedStoryData' => 'PhabricatorFeedDAO',
'PhabricatorFeedStoryDifferential' => 'PhabricatorFeedStory', 'PhabricatorFeedStoryDifferential' => 'PhabricatorFeedStory',
'PhabricatorFeedStoryManiphest' => 'PhabricatorFeedStory', 'PhabricatorFeedStoryManiphest' => 'PhabricatorFeedStory',
'PhabricatorFeedStoryManiphestAggregate' => 'PhabricatorFeedStoryAggregate',
'PhabricatorFeedStoryNotification' => 'PhabricatorFeedDAO', 'PhabricatorFeedStoryNotification' => 'PhabricatorFeedDAO',
'PhabricatorFeedStoryPhriction' => 'PhabricatorFeedStory', 'PhabricatorFeedStoryPhriction' => 'PhabricatorFeedStory',
'PhabricatorFeedStoryProject' => 'PhabricatorFeedStory', 'PhabricatorFeedStoryProject' => 'PhabricatorFeedStory',

View file

@ -139,4 +139,8 @@ abstract class PhabricatorFeedStory {
return $text; return $text;
} }
public function getNotificationAggregations() {
return array();
}
} }

View file

@ -0,0 +1,74 @@
<?php
/*
* Copyright 2012 Facebook, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
abstract class PhabricatorFeedStoryAggregate extends PhabricatorFeedStory {
private $aggregateStories = array();
public function getHasViewed() {
return head($this->getAggregateStories())->getHasViewed();
}
public function getRequiredHandlePHIDs() {
$phids = array();
foreach ($this->getAggregateStories() as $story) {
$phids[] = $story->getRequiredHandlePHIDs();
}
return array_mergev($phids);
}
public function getRequiredObjectPHIDs() {
$phids = array();
foreach ($this->getAggregateStories() as $story) {
$phids[] = $story->getRequiredObjectPHIDs();
}
return array_mergev($phids);
}
protected function getAuthorPHIDs() {
$authors = array();
foreach ($this->getAggregateStories() as $story) {
$authors[] = $story->getStoryData()->getAuthorPHID();
}
return array_unique(array_filter($authors));
}
protected function getDataValues($key, $default) {
$result = array();
foreach ($this->getAggregateStories() as $key => $story) {
$result[$key] = $story->getStoryData()->getValue($key, $default);
}
return $result;
}
final public function setAggregateStories(array $aggregate_stories) {
assert_instances_of($aggregate_stories, 'PhabricatorFeedStory');
$this->aggregateStories = $aggregate_stories;
return $this;
}
final public function getAggregateStories() {
return $this->aggregateStories;
}
final public function getNotificationAggregations() {
throw new Exception(
"You can not get aggregations for an aggregate story.");
}
}

View file

@ -111,4 +111,19 @@ final class PhabricatorFeedStoryManiphest
return $one_line; return $one_line;
} }
public function getNotificationAggregations() {
$class = get_class($this);
$phid = $this->getStoryData()->getValue('taskPHID');
$read = (int)$this->getHasViewed();
// Don't aggregate updates separated by more than 2 hours.
$block = (int)($this->getEpoch() / (60 * 60 * 2));
return array(
"{$class}:{$phid}:{$read}:{$block}"
=> 'PhabricatorFeedStoryManiphestAggregate',
);
}
} }

View file

@ -0,0 +1,86 @@
<?php
/*
* Copyright 2012 Facebook, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
final class PhabricatorFeedStoryManiphestAggregate
extends PhabricatorFeedStoryAggregate {
public function renderView() {
return null;
}
public function renderNotificationView() {
$data = $this->getStoryData();
$task_link = $this->linkTo($data->getValue('taskPHID'));
$authors = $this->getAuthorPHIDs();
// TODO: These aren't really translatable because linkTo() returns a
// string, not an object with a gender.
switch (count($authors)) {
case 1:
$author = $this->linkTo(array_shift($authors));
$title = pht(
'%s made multiple updates to %s',
$author,
$task_link);
break;
case 2:
$author1 = $this->linkTo(array_shift($authors));
$author2 = $this->linkTo(array_shift($authors));
$title = pht(
'%s and %s made multiple updates to %s',
$author1,
$author2,
$task_link);
break;
case 3:
$author1 = $this->linkTo(array_shift($authors));
$author2 = $this->linkTo(array_shift($authors));
$author3 = $this->linkTo(array_shift($authors));
$title = pht(
'%s, %s, and %s made multiple updates to %s',
$author1,
$author2,
$author3,
$task_link);
break;
default:
$author1 = $this->linkTo(array_shift($authors));
$author2 = $this->linkTo(array_shift($authors));
$others = count($authors);
$title = pht(
'%s, %s, and %d others made multiple updates to %s',
$author1,
$author2,
$others,
$task_link);
break;
}
$view = new PhabricatorNotificationStoryView();
$view->setEpoch($this->getEpoch());
$view->setViewed($this->getHasViewed());
$view->setTitle($title);
return $view;
}
}

View file

@ -27,6 +27,114 @@ final class PhabricatorNotificationBuilder {
public function buildView() { public function buildView() {
$stories = $this->stories; $stories = $this->stories;
$stories = mpull($stories, null, 'getChronologicalKey');
// Aggregate notifications. Generally, we can aggregate notifications only
// by object, e.g. "a updated T123" and "b updated T123" can become
// "a and b updated T123", but we can't combine "a updated T123" and
// "a updated T234" into "a updated T123 and T234" because there would be
// nowhere sensible for the notification to link to, and no reasonable way
// to unambiguously clear it.
// Each notification emits keys it can aggregate on. For instance, if this
// story is "a updated T123", it might emit a key like this:
//
// task:phid123:unread => PhabricatorFeedStoryManiphestAggregate
//
// All the unread notifications about the task with PHID "phid123" will
// emit the same key, telling us we can aggregate them into a single
// story of type "PhabricatorFeedStoryManiphestAggregate", which could
// read like "a and b updated T123".
//
// A story might be able to aggregate in multiple ways. Although this is
// unlikely for stories in a notification context, stories in a feed context
// can also aggregate by actor:
//
// task:phid123 => PhabricatorFeedStoryManiphestAggregate
// actor:user123 => PhabricatorFeedStoryActorAggregate
//
// This means the story can either become "a and b updated T123" or
// "a updated T123 and T456". When faced with multiple possibilities, it's
// our job to choose the best aggregation.
//
// For now, we use a simple greedy algorithm and repeatedly select the
// aggregate story which consumes the largest number of individual stories
// until no aggregate story exists that consumes more than one story.
// Build up a map of all the possible aggregations.
$chronokey_map = array();
$aggregation_map = array();
$agg_types = array();
foreach ($stories as $chronokey => $story) {
$chronokey_map[$chronokey] = $story->getNotificationAggregations();
foreach ($chronokey_map[$chronokey] as $key => $type) {
$agg_types[$key] = $type;
$aggregation_map[$key]['keys'][$chronokey] = true;
}
}
// Repeatedly select the largest available aggregation until none remain.
$aggregated_stories = array();
while ($aggregation_map) {
// Count the size of each aggregation, removing any which will consume
// fewer than 2 stories.
foreach ($aggregation_map as $key => $dict) {
$size = count($dict['keys']);
if ($size > 1) {
$aggregation_map[$key]['size'] = $size;
} else {
unset($aggregation_map[$key]);
}
}
// If we're out of aggregations, break out.
if (!$aggregation_map) {
break;
}
// Select the aggregation we're going to make, and remove it from the
// map.
$aggregation_map = isort($aggregation_map, 'size');
$agg_info = idx(last($aggregation_map), 'keys');
$agg_key = last_key($aggregation_map);
unset($aggregation_map[$agg_key]);
// Select all the stories it aggregates, and remove them from the master
// list of stories and from all other possible aggregations.
$sub_stories = array();
foreach ($agg_info as $chronokey => $ignored) {
$sub_stories[$chronokey] = $stories[$chronokey];
unset($stories[$chronokey]);
foreach ($chronokey_map[$chronokey] as $key => $type) {
unset($aggregation_map[$key]['keys'][$chronokey]);
}
unset($chronokey_map[$chronokey]);
}
// Build the aggregate story.
krsort($sub_stories);
$story_class = $agg_types[$agg_key];
$conv = array(head($sub_stories)->getStoryData());
$new_story = newv($story_class, $conv);
$new_story->setAggregateStories($sub_stories);
$aggregated_stories[] = $new_story;
}
// Combine the aggregate stories back into the list of stories.
$stories = array_merge($stories, $aggregated_stories);
$stories = mpull($stories, null, 'getChronologicalKey');
krsort($stories);
$handles = array(); $handles = array();
$objects = array(); $objects = array();
@ -39,7 +147,6 @@ final class PhabricatorNotificationBuilder {
$null_view = new AphrontNullView(); $null_view = new AphrontNullView();
//TODO ADD NOTIFICATIONS HEADER
foreach ($stories as $story) { foreach ($stories as $story) {
$story->setHandles($handles); $story->setHandles($handles);
$view = $story->renderNotificationView(); $view = $story->renderNotificationView();