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) {