Group reviewer updates in the UI (parser)

Introduces GrReviewerUpdatesParser that processes individual reviewer
updates and groups them. This essentially moves implementation from
/c/94490/ to client side to gather requirements ahead of server-side
implementation. Ultimately, this class will simply be removed once
server-side generates grouped updates.

Sequential updates are grouped if:
- They were performed within short timeframe (6 seconds)
- Made by the same person
- Non-change updates are discarded within a group
- Groups with no-change updates are discarded (eg CC -> CC)

Bug: Issue 4383, Issue 5256
Change-Id: Ieb405d4b5562294adb23bdd14879e9e67737890f
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser.js
new file mode 100644
index 0000000..f61cd1b
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser.js
@@ -0,0 +1,190 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// 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.
+(function(window) {
+  'use strict';
+
+  // Prevent redefinition.
+  if (window.GrReviewerUpdatesParser) { return; }
+
+  function GrReviewerUpdatesParser(change) {
+    // TODO (viktard): Polyfill Object.assign for IE.
+    this.result = Object.assign({}, change);
+    this._lastState = {};
+  };
+
+  GrReviewerUpdatesParser.parse = function(change) {
+    if (!change.messages || !change.reviewer_updates) {
+      return change;
+    }
+    var parser = new GrReviewerUpdatesParser(change);
+    parser._filterRemovedMessages();
+    parser._groupUpdates();
+    parser._formatUpdates();
+    return parser.result;
+  };
+
+  GrReviewerUpdatesParser.REVIEWER_UPDATE_THRESHOLD_MILLIS = 6000;
+
+  GrReviewerUpdatesParser.prototype.result = null;
+  GrReviewerUpdatesParser.prototype._batch = null;
+  GrReviewerUpdatesParser.prototype._updateItems = null;
+  GrReviewerUpdatesParser.prototype._lastState = null;
+
+  /**
+   * Removes messages that describe removed reviewers, since reviewer_updates
+   * are used.
+   */
+  GrReviewerUpdatesParser.prototype._filterRemovedMessages = function() {
+    this.result.messages = this.result.messages.filter(function(message) {
+      return message.tag !== 'autogenerated:gerrit:deleteReviewer';
+    });
+  };
+
+  /**
+   * Is a part of _groupUpdates(). Creates a new batch of updates.
+   * @param {Object} update instance of ReviewerUpdateInfo
+   */
+  GrReviewerUpdatesParser.prototype._startBatch = function(update) {
+    this._updateItems = [];
+    return {
+      author: update.updated_by,
+      date: update.updated,
+      type: 'REVIEWER_UPDATE',
+    };
+  };
+
+  /**
+   * Is a part of _groupUpdates(). Validates current batch:
+   * - filters out updates that don't change reviewer state.
+   * - updates current reviewer state.
+   * @param {Object} update instance of ReviewerUpdateInfo
+   */
+  GrReviewerUpdatesParser.prototype._completeBatch = function(update) {
+    var items = [];
+    for (var accountId in this._updateItems) {
+      if (!this._updateItems.hasOwnProperty(accountId)) continue;
+      var updateItem = this._updateItems[accountId];
+      if (this._lastState[accountId] !== updateItem.state) {
+        this._lastState[accountId] = updateItem.state;
+        items.push(updateItem);
+      }
+    }
+    if (items.length) {
+      this._batch.updates = items;
+    }
+  };
+
+  /**
+   * Groups reviewer updates. Sequential updates are grouped if:
+   * - They were performed within short timeframe (6 seconds)
+   * - Made by the same person
+   * - Non-change updates are discarded within a group
+   * - Groups with no-change updates are discarded (eg CC -> CC)
+   */
+  GrReviewerUpdatesParser.prototype._groupUpdates = function() {
+    var updates = this.result.reviewer_updates;
+    var newUpdates = updates.reduce(function(newUpdates, update) {
+      if (!this._batch) {
+        this._batch = this._startBatch(update);
+      }
+      var updateDate = util.parseDate(update.updated).getTime();
+      var batchUpdateDate = util.parseDate(this._batch.date).getTime();
+      var reviewerId = update.reviewer._account_id.toString();
+      if (updateDate - batchUpdateDate >
+          GrReviewerUpdatesParser.REVIEWER_UPDATE_THRESHOLD_MILLIS ||
+          update.updated_by._account_id !== this._batch.author._account_id) {
+        // Next sequential update should form new group.
+        this._completeBatch();
+        if (this._batch.updates && this._batch.updates.length) {
+          newUpdates.push(this._batch);
+        }
+        this._batch = this._startBatch(update);
+      }
+      this._updateItems[reviewerId] = {
+        reviewer: update.reviewer,
+        state: update.state,
+      };
+      if (this._lastState[reviewerId]) {
+        this._updateItems[reviewerId].prev_state = this._lastState[reviewerId];
+      }
+      return newUpdates;
+    }.bind(this), []);
+    this._completeBatch();
+    if (this._batch.updates && this._batch.updates.length) {
+      newUpdates.push(this._batch);
+    }
+    this.result.reviewer_updates = newUpdates;
+  };
+
+  /**
+   * Generates update message for reviewer state change.
+   * @param {string} prev previous reviewer state.
+   * @param {string} state current reviewer state.
+   * @return {string}
+   */
+  GrReviewerUpdatesParser.prototype._getUpdateMessage = function(prev, state) {
+    if (prev === 'REMOVED' || !prev) {
+      return 'added to ' + state + ': ';
+    } else if (state === 'REMOVED') {
+      if (prev) {
+        return 'removed from ' + prev + ': ';
+      } else {
+        return 'removed : ';
+      }
+    } else {
+      return 'moved from ' + prev + ' to ' + state + ': ';
+    }
+  };
+
+  /**
+   * Groups updates for same category (eg CC->CC) into a hash arrays of
+   * reviewers.
+   * @param {!Array<!Object>} updates Array of ReviewerUpdateItemInfo.
+   * @return {!Object} Hash of arrays of AccountInfo, message as key.
+   */
+  GrReviewerUpdatesParser.prototype._groupUpdatesByMessage = function(updates) {
+    return updates.reduce(function(result, item) {
+      var message = this._getUpdateMessage(item.prev_state, item.state);
+      if (!result[message]) {
+        result[message] = [];
+      }
+      result[message].push(item.reviewer);
+      return result;
+    }.bind(this), {});
+  };
+
+  /**
+   * Generates text messages for grouped reviewer updates.
+   * Formats reviewer updates to a (not yet implemented) EventInfo instance.
+   * @see https://gerrit-review.googlesource.com/c/94490/
+   */
+  GrReviewerUpdatesParser.prototype._formatUpdates = function() {
+    this.result.reviewer_updates.forEach(function(update) {
+      var grouppedReviewers = this._groupUpdatesByMessage(update.updates);
+      var newUpdates = [];
+      for (var message in grouppedReviewers) {
+        if (grouppedReviewers.hasOwnProperty(message)) {
+          newUpdates.push({
+            message: message,
+            reviewers: grouppedReviewers[message],
+          });
+        }
+      }
+      update.updates = newUpdates;
+    }.bind(this));
+  };
+
+  window.GrReviewerUpdatesParser = GrReviewerUpdatesParser;
+
+})(window);
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser_test.html b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser_test.html
new file mode 100644
index 0000000..86b5549
--- /dev/null
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-reviewer-updates-parser_test.html
@@ -0,0 +1,197 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2017 The Android Open Source Project
+
+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.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-reviewer-updates-parser</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+
+<link rel="import" href="../../../bower_components/iron-test-helpers/iron-test-helpers.html">
+
+<script src="../../../scripts/util.js"></script>
+<script src="gr-reviewer-updates-parser.js"></script>
+
+<script>
+  suite('gr-reviewer-updates-parser tests', function() {
+    var sandbox;
+    var instance;
+
+    setup(function() {
+      sandbox = sinon.sandbox.create();
+    });
+
+    teardown(function() {
+      sandbox.restore();
+    });
+
+    test('filter removed messages', function() {
+      var change = {
+          messages: [
+            {
+              message: 'msg1',
+              tag: 'autogenerated:gerrit:deleteReviewer',
+            },
+            {
+              message: 'msg2',
+              tag: 'foo',
+            }
+          ],
+      };
+      instance = new GrReviewerUpdatesParser(change);
+      instance._filterRemovedMessages();
+      assert.deepEqual(instance.result, {
+        messages: [{
+          message: 'msg2',
+          tag: 'foo',
+        }],
+      });
+    });
+
+    test('group reviewer updates', function() {
+      var reviewer1 = {_account_id: 1};
+      var reviewer2 = {_account_id: 2};
+      var date1 = '2017-01-26 12:11:50.000000000';
+      var date2 = '2017-01-26 12:11:55.000000000'; // Within threshold.
+      var date3 = '2017-01-26 12:33:50.000000000';
+      var date4 = '2017-01-26 12:44:50.000000000';
+      var makeItem = function(state, reviewer, opt_date, opt_author) {
+        return {
+          reviewer: reviewer,
+          updated: opt_date || date1,
+          updated_by: opt_author || reviewer1,
+          state: state,
+        };
+      };
+      var change = {
+        reviewer_updates: [
+          makeItem('REVIEWER', reviewer1), // New group.
+          makeItem('CC', reviewer2), // Appended.
+          makeItem('REVIEWER', reviewer2, date2), // Overrides previous one.
+
+          makeItem('CC', reviewer1, date2, reviewer2), // New group.
+
+          makeItem('REMOVED', reviewer2, date3), // Group has no state change.
+          makeItem('REVIEWER', reviewer2, date3),
+
+          makeItem('CC', reviewer1, date4), // No change, removed.
+          makeItem('REVIEWER', reviewer1, date4), // Forms new group
+          makeItem('REMOVED', reviewer2, date4), // Should be grouped.
+        ],
+      };
+
+      instance = new GrReviewerUpdatesParser(change);
+      instance._groupUpdates();
+      change = instance.result;
+
+      assert.equal(change.reviewer_updates.length, 3);
+      assert.equal(change.reviewer_updates[0].updates.length, 2);
+      assert.equal(change.reviewer_updates[1].updates.length, 1);
+      assert.equal(change.reviewer_updates[2].updates.length, 2);
+
+      assert.equal(change.reviewer_updates[0].date, date1);
+      assert.deepEqual(change.reviewer_updates[0].author, reviewer1);
+      assert.deepEqual(change.reviewer_updates[0].updates, [
+        {
+          reviewer: reviewer1,
+          state: 'REVIEWER',
+        },
+        {
+          reviewer: reviewer2,
+          state: 'REVIEWER',
+        },
+      ]);
+
+      assert.equal(change.reviewer_updates[1].date, date2);
+      assert.deepEqual(change.reviewer_updates[1].author, reviewer2);
+      assert.deepEqual(change.reviewer_updates[1].updates, [
+        {
+          reviewer: reviewer1,
+          state: 'CC',
+          prev_state: 'REVIEWER',
+        },
+      ]);
+
+      assert.equal(change.reviewer_updates[2].date, date4);
+      assert.deepEqual(change.reviewer_updates[2].author, reviewer1);
+      assert.deepEqual(change.reviewer_updates[2].updates, [
+        {
+          reviewer: reviewer1,
+          prev_state: 'CC',
+          state: 'REVIEWER',
+        },
+        {
+          reviewer: reviewer2,
+          prev_state: 'REVIEWER',
+          state: 'REMOVED',
+        },
+      ]);
+    });
+
+    test('format reviewer updates', function() {
+      var reviewer1 = {_account_id: 1};
+      var reviewer2 = {_account_id: 2};
+      var makeItem = function(prev, state, opt_reviewer) {
+        return {
+          reviewer: opt_reviewer || reviewer1,
+          prev_state: prev,
+          state: state,
+        };
+      };
+      var makeUpdate = function(items) {
+        return {
+          author: reviewer1,
+          updated: '',
+          updates: items,
+        };
+      };
+      var change = {
+          reviewer_updates: [
+            makeUpdate([
+              makeItem(undefined, 'CC'),
+              makeItem(undefined, 'CC', reviewer2)
+            ]),
+            makeUpdate([
+              makeItem('CC', 'REVIEWER'),
+              makeItem('REVIEWER', 'REMOVED'),
+              makeItem('REMOVED', 'REVIEWER'),
+              makeItem(undefined, 'REVIEWER', reviewer2),
+            ]),
+          ],
+      };
+
+      instance = new GrReviewerUpdatesParser(change);
+      instance._formatUpdates();
+
+      assert.equal(change.reviewer_updates.length, 2);
+      assert.equal(change.reviewer_updates[0].updates.length, 1);
+      assert.equal(change.reviewer_updates[1].updates.length, 3);
+
+      var items = change.reviewer_updates[0].updates;
+      assert.equal(items[0].message, 'added to CC: ');
+      assert.deepEqual(items[0].reviewers, [reviewer1, reviewer2]);
+
+      items = change.reviewer_updates[1].updates;
+      assert.equal(items[0].message, 'moved from CC to REVIEWER: ');
+      assert.deepEqual(items[0].reviewers, [reviewer1]);
+      assert.equal(items[1].message, 'removed from REVIEWER: ');
+      assert.deepEqual(items[1].reviewers, [reviewer1]);
+      assert.equal(items[2].message, 'added to REVIEWER: ');
+      assert.deepEqual(items[2].reviewers, [reviewer1, reviewer2]);
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/test/index.html b/polygerrit-ui/app/test/index.html
index ffd3b8e..34a3e0e 100644
--- a/polygerrit-ui/app/test/index.html
+++ b/polygerrit-ui/app/test/index.html
@@ -97,6 +97,7 @@
     'shared/gr-linked-chip/gr-linked-chip_test.html',
     'shared/gr-linked-text/gr-linked-text_test.html',
     'shared/gr-rest-api-interface/gr-rest-api-interface_test.html',
+    'shared/gr-rest-api-interface/gr-reviewer-updates-parser_test.html',
     'shared/gr-select/gr-select_test.html',
     'shared/gr-storage/gr-storage_test.html',
   ].forEach(function(file) {