Make messages more conversational

Attempts to consume a change message to create a shorter and more
legible format. If the function encounters unexpected characters at any
point, it sets the _successfulParse flag to false and terminates,
causing the UI to fall back to displaying the entirety of the change
message.

A successful parse results in a one-liner that reads:
`${AVATAR} voted ${VOTES} and left ${NUM} comment(s) on ${PATCHSET}`

The ultimate goal/motivation for this change is to conserve vertical
space in the message list.

Change-Id: I857c17aa47a3ac1a0d17ad3dbddd563fecedbad3
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message.html b/polygerrit-ui/app/elements/change/gr-message/gr-message.html
index 00157ab..5608dec 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.html
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.html
@@ -178,20 +178,34 @@
           <gr-account-label
               account="[[author]]"
               hide-avatar></gr-account-label>
-          <template is="dom-repeat" items="[[_getScores(message)]]" as="score">
-            <span class$="score [[_computeScoreClass(score, labelExtremes)]]">
-              [[score.label]] [[score.value]]
-            </span>
+          <template is="dom-if" if="[[_successfulParse]]">
+            <template is="dom-if" if="[[_parsedVotes.length]]">voted</template>
+            <template is="dom-repeat" items="[[_parsedVotes]]" as="score">
+              <span class$="score [[_computeScoreClass(score, labelExtremes)]]">
+                [[score.label]] [[score.value]]
+              </span>
+            </template>
+            [[_computeConversationalString(_parsedVotes, _parsedPatchNum, _parsedCommentCount)]]
           </template>
         </div>
         <template is="dom-if" if="[[message.message]]">
           <div class="content">
-            <div class="message hideOnOpen">[[message.message]]</div>
-            <gr-formatted-text
-                no-trailing-margin
-                class="message hideOnCollapsed"
-                content="[[message.message]]"
-                config="[[_projectConfig.commentlinks]]"></gr-formatted-text>
+            <template is="dom-if" if="[[_successfulParse]]">
+              <div class="message hideOnOpen">[[_parsedChangeMessage]]</div>
+              <gr-formatted-text
+                  no-trailing-margin
+                  class="message hideOnCollapsed"
+                  content="[[_parsedChangeMessage]]"
+                  config="[[_projectConfig.commentlinks]]"></gr-formatted-text>
+            </template>
+            <template is="dom-if" if="[[!_successfulParse]]">
+              <div class="message hideOnOpen">[[message.message]]</div>
+                <gr-formatted-text
+                    no-trailing-margin
+                    class="message hideOnCollapsed"
+                    content="[[message.message]]"
+                    config="[[_projectConfig.commentlinks]]"></gr-formatted-text>
+            </template>
             <div class="replyContainer" hidden$="[[!showReplyButton]]" hidden>
               <gr-button link small on-tap="_handleReplyTap">Reply</gr-button>
             </div>
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message.js b/polygerrit-ui/app/elements/change/gr-message/gr-message.js
index 0590c73..addd660 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.js
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.js
@@ -17,7 +17,8 @@
 (function() {
   'use strict';
 
-  const PATCH_SET_PREFIX_PATTERN = /^Patch Set \d+: /;
+  const PATCH_SET_PREFIX_PATTERN = /^Patch Set (\d)+:[ ]?/;
+  const COMMENTS_COUNT_PATTERN = /^\((\d+)( inline)? comments?\)$/;
   const LABEL_TITLE_SCORE_PATTERN = /^([A-Za-z0-9-]+)([+-]\d+)$/;
 
   Polymer({
@@ -101,10 +102,17 @@
         type: Boolean,
         value: false,
       },
+
+      _parsedPatchNum: String,
+      _parsedCommentCount: String,
+      _parsedVotes: Array,
+      _parsedChangeMessage: String,
+      _successfulParse: Boolean,
     },
 
     observers: [
       '_updateExpandedClass(message.expanded)',
+      '_consumeMessage(message.message)',
     ],
 
     ready() {
@@ -184,19 +192,6 @@
       return event.type === 'REVIEWER_UPDATE';
     },
 
-    _getScores(message) {
-      if (!message.message) { return []; }
-      const line = message.message.split('\n', 1)[0];
-      const patchSetPrefix = PATCH_SET_PREFIX_PATTERN;
-      if (!line.match(patchSetPrefix)) { return []; }
-      const scoresRaw = line.split(patchSetPrefix)[1];
-      if (!scoresRaw) { return []; }
-      return scoresRaw.split(' ')
-          .map(s => s.match(LABEL_TITLE_SCORE_PATTERN))
-          .filter(ms => ms && ms.length === 3)
-          .map(ms => ({label: ms[1], value: ms[2]}));
-    },
-
     _computeScoreClass(score, labelExtremes) {
       const classes = [];
       if (score.value > 0) {
@@ -260,5 +255,73 @@
       e.stopPropagation();
       this.set('message.expanded', !this.message.expanded);
     },
+
+    /**
+     * Attempts to consume a change message to create a shorter and more legible
+     * format. If the function encounters unexpected characters at any point, it
+     * sets the _successfulParse flag to false and terminates, causing the UI to
+     * fall back to displaying the entirety of the change message.
+     *
+     * A successful parse results in a one-liner that reads:
+     * `${AVATAR} voted ${VOTES} and left ${NUM} comment(s) on ${PATCHSET}`
+     *
+     * @param {string} text
+     */
+    _consumeMessage(text) {
+      this._parsedPatchNum = '';
+      this._parsedCommentCount = '';
+      this._parsedChangeMessage = '';
+      this._parsedVotes = [];
+      if (!text) {
+        // No message body means nothing to parse.
+        this._successfulParse = false;
+        return;
+      }
+      const lines = text.split('\n');
+      const messageLines = lines.shift().split(PATCH_SET_PREFIX_PATTERN);
+      if (!messageLines[1]) {
+        // Message is in an unexpected format.
+        this._successfulParse = false;
+        return;
+      }
+      this._parsedPatchNum = messageLines[1];
+      if (messageLines[2]) {
+        // Content after the colon is always vote information. If it is in the
+        // most up to date schema, parse it. Otherwise, cancel the parsing
+        // completely.
+        let match;
+        for (const score of messageLines[2].split(' ')) {
+          match = score.match(LABEL_TITLE_SCORE_PATTERN);
+          if (!match || match.length !== 3) {
+            this._successfulParse = false;
+            return;
+          }
+          this._parsedVotes.push({label: match[1], value: match[2]});
+        }
+      }
+      // Remove empty line.
+      lines.shift();
+      if (lines.length) {
+        const commentMatch = lines[0].match(COMMENTS_COUNT_PATTERN);
+        if (commentMatch) {
+          this._parsedCommentCount = commentMatch[1];
+          // Remove comment line and the following empty line.
+          lines.splice(0, 2);
+        }
+        this._parsedChangeMessage = lines.join('\n');
+      }
+      this._successfulParse = true;
+    },
+
+    _computeConversationalString(votes, patchNum, commentCount) {
+      let clause = ' on Patch Set ' + patchNum;
+      if (commentCount) {
+        let commentStr = ' comment';
+        if (parseInt(commentCount, 10) > 1) { commentStr += 's'; }
+        clause = ' left ' + commentCount + commentStr + clause;
+        if (votes.length) { clause = ' and' + clause; }
+      }
+      return clause;
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.html b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.html
index 870f366..6bb4618 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message_test.html
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message_test.html
@@ -169,7 +169,9 @@
         author: {},
         expanded: false,
         message: 'Patch Set 1: Verified+1 Code-Review-2 Trybot-Ready+1',
+        _revision_number: 1,
       };
+      element.comments = {};
       element.labelExtremes = {
         'Verified': {max: 1, min: -1},
         'Code-Review': {max: 2, min: -2},
@@ -199,5 +201,145 @@
       const scoreChips = Polymer.dom(element.root).querySelectorAll('.score');
       assert.equal(scoreChips.length, 0);
     });
+
+    suite('_consumeMessage', () => {
+      const assertConsumeFailed = str => {
+        element._consumeMessage(str);
+        assert.isFalse(element._successfulParse);
+      };
+
+      test('no message body', () => {
+        assertConsumeFailed('');
+      });
+
+      test('known old schema', () => {
+        const str = 'Patch Set 1: Looks good to me, approved; Verified';
+        assertConsumeFailed(str);
+      });
+
+      test('known old schema 2', () => {
+        const str = [
+          'Patch Set 2: Looks good to me',
+          '',
+          'Patch set 2 compiles, and runs as expected.',
+        ].join('\n');
+        assertConsumeFailed(str);
+      });
+
+      test('known old schema 3', () => {
+        const str = 'Patch Set 2: (1 inline comment)';
+        assertConsumeFailed(str);
+      });
+
+      test('known old schema 4', () => {
+        const str = [
+          'Patch Set 2: Looks good to me',
+          '',
+          '(1 inline comment)',
+        ].join('\n');
+        assertConsumeFailed(str);
+      });
+
+      test('just change message', () => {
+        const str = [
+          'Patch Set 2:',
+          '',
+          'I think you should reconsider this approach.',
+          'It really makes no sense.',
+        ].join('\n');
+
+        element._consumeMessage(str);
+
+        assert.isTrue(element._successfulParse);
+        assert.equal(element._parsedPatchNum, '2');
+        assert.equal(element._parsedCommentCount, '');
+        assert.deepEqual(element._parsedVotes, []);
+        assert.equal(element._parsedChangeMessage, [
+          'I think you should reconsider this approach.',
+          'It really makes no sense.',
+        ].join('\n'));
+      });
+
+      test('just votes', () => {
+        element._consumeMessage('Patch Set 2: Code-Review-Label+1 Verified+1');
+
+        assert.isTrue(element._successfulParse);
+        assert.equal(element._parsedPatchNum, '2');
+        assert.equal(element._parsedCommentCount, '');
+        assert.deepEqual(element._parsedVotes, [
+          {label: 'Code-Review-Label', value: '+1'},
+          {label: 'Verified', value: '+1'},
+        ]);
+        assert.equal(element._parsedChangeMessage, '');
+      });
+
+      test('just comments', () => {
+        const str = [
+          'Patch Set 2:',
+          '',
+          '(8 comments)',
+        ].join('\n');
+
+        element._consumeMessage(str);
+
+        assert.isTrue(element._successfulParse);
+        assert.equal(element._parsedPatchNum, '2');
+        assert.equal(element._parsedCommentCount, '8');
+        assert.deepEqual(element._parsedVotes, []);
+        assert.equal(element._parsedChangeMessage, '');
+      });
+
+      test('vote with comments and change message', () => {
+        const str = [
+          'Patch Set 2: Code-Review-Label+1 Verified+1',
+          '',
+          '(1 comment)',
+          '',
+          'LGTM',
+          '',
+          'Nice work, just one nit.',
+        ].join('\n');
+
+        element._consumeMessage(str);
+
+        assert.isTrue(element._successfulParse);
+        assert.equal(element._parsedPatchNum, '2');
+        assert.equal(element._parsedCommentCount, '1');
+        assert.deepEqual(element._parsedVotes, [
+          {label: 'Code-Review-Label', value: '+1'},
+          {label: 'Verified', value: '+1'},
+        ]);
+        assert.equal(element._parsedChangeMessage, [
+          'LGTM',
+          '',
+          'Nice work, just one nit.',
+        ].join('\n'));
+      });
+
+      test('vote with change message', () => {
+        const str = [
+          'Patch Set 2: Code-Review-Label+2 Verified+1',
+          '',
+          'LGTM',
+          '',
+          'Nice work.',
+        ].join('\n');
+
+        element._consumeMessage(str);
+
+        assert.isTrue(element._successfulParse);
+        assert.equal(element._parsedPatchNum, '2');
+        assert.equal(element._parsedCommentCount, '');
+        assert.deepEqual(element._parsedVotes, [
+          {label: 'Code-Review-Label', value: '+2'},
+          {label: 'Verified', value: '+1'},
+        ]);
+        assert.equal(element._parsedChangeMessage, [
+          'LGTM',
+          '',
+          'Nice work.',
+        ].join('\n'));
+      });
+    });
   });
 </script>