/** * @license * 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. */ import '../../../scripts/bundled-polymer.js'; import '../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.js'; import '../../shared/gr-rest-api-interface/gr-rest-api-interface.js'; import {mixinBehaviors} from '@polymer/polymer/lib/legacy/class.js'; import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners.js'; import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin.js'; import {PolymerElement} from '@polymer/polymer/polymer-element.js'; import {htmlTemplate} from './gr-comment-api_html.js'; const PARENT = 'PARENT'; /** * Construct a change comments object, which can be data-bound to child * elements of that which uses the gr-comment-api. * * @constructor * @param {!Object} comments * @param {!Object} robotComments * @param {!Object} drafts * @param {number} changeNum */ class ChangeComments { constructor(comments, robotComments, drafts, changeNum) { // TODO(taoalpha): replace these with exported methods from patchset behavior this._patchNumEquals = Gerrit.PatchSetBehavior.patchNumEquals; this._isMergeParent = Gerrit.PatchSetBehavior.isMergeParent; this._getParentIndex = Gerrit.PatchSetBehavior.getParentIndex; this._comments = comments; this._robotComments = robotComments; this._drafts = drafts; this._changeNum = changeNum; } get comments() { return this._comments; } get drafts() { return this._drafts; } get robotComments() { return this._robotComments; } /** * Get an object mapping file paths to a boolean representing whether that * path contains diff comments in the given patch set (including drafts and * robot comments). * * Paths with comments are mapped to true, whereas paths without comments * are not mapped. * * @param {Gerrit.PatchRange=} opt_patchRange The patch-range object containing * patchNum and basePatchNum properties to represent the range. * @return {!Object} */ getPaths(opt_patchRange) { const responses = [this.comments, this.drafts, this.robotComments]; const commentMap = {}; for (const response of responses) { for (const path in response) { if (response.hasOwnProperty(path) && response[path].some(c => { // If don't care about patch range, we know that the path exists. if (!opt_patchRange) { return true; } return this._isInPatchRange(c, opt_patchRange); })) { commentMap[path] = true; } } } return commentMap; } /** * Gets all the comments and robot comments for the given change. * * @param {number=} opt_patchNum * @return {!Object} */ getAllPublishedComments(opt_patchNum) { return this.getAllComments(false, opt_patchNum); } /** * Gets all the comments for a particular thread group. Used for refreshing * comments after the thread group has already been built. * * @param {string} rootId * @return {!Array} an array of comments */ getCommentsForThread(rootId) { const allThreads = this.getAllThreadsForChange(); const threadMatch = allThreads.find(t => t.rootId === rootId); // In the event that a single draft comment was removed by the thread-list // and the diff view is updating comments, there will no longer be a thread // found. In this case, return null. return threadMatch ? threadMatch.comments : null; } /** * Filters an array of comments by line and side * * @param {!Array} comments * @param {boolean} parentOnly whether the only comments returned should have * the side attribute set to PARENT * @param {string} commentSide whether the comment was left on the left or the * right side regardless or unified or side-by-side * @param {number=} opt_line line number, can be undefined if file comment * @return {!Array} an array of comments */ _filterCommentsBySideAndLine(comments, parentOnly, commentSide, opt_line) { return comments.filter(c => { // if parentOnly, only match comments with PARENT for the side. let sideMatch = parentOnly ? c.side === PARENT : c.side !== PARENT; if (parentOnly) { sideMatch = sideMatch && c.side === PARENT; } return sideMatch && c.line === opt_line; }).map(c => { c.__commentSide = commentSide; return c; }); } /** * Gets all the comments and robot comments for the given change. * * @param {boolean=} opt_includeDrafts * @param {number=} opt_patchNum * @return {!Object} */ getAllComments(opt_includeDrafts, opt_patchNum) { const paths = this.getPaths(); const publishedComments = {}; for (const path of Object.keys(paths)) { let commentsToAdd = this.getAllCommentsForPath(path, opt_patchNum); if (opt_includeDrafts) { const drafts = this.getAllDraftsForPath(path, opt_patchNum) .map(d => Object.assign({__draft: true}, d)); commentsToAdd = commentsToAdd.concat(drafts); } publishedComments[path] = commentsToAdd; } return publishedComments; } /** * Gets all the comments and robot comments for the given change. * * @param {number=} opt_patchNum * @return {!Object} */ getAllDrafts(opt_patchNum) { const paths = this.getPaths(); const drafts = {}; for (const path of Object.keys(paths)) { drafts[path] = this.getAllDraftsForPath(path, opt_patchNum); } return drafts; } /** * Get the comments (robot comments) for a path and optional patch num. * * @param {!string} path * @param {number=} opt_patchNum * @param {boolean=} opt_includeDrafts * @return {!Array} */ getAllCommentsForPath(path, opt_patchNum, opt_includeDrafts) { const comments = this._comments[path] || []; const robotComments = this._robotComments[path] || []; let allComments = comments.concat(robotComments); if (opt_includeDrafts) { const drafts = this.getAllDraftsForPath(path) .map(d => Object.assign({__draft: true}, d)); allComments = allComments.concat(drafts); } if (!opt_patchNum) { return allComments; } return (allComments || []).filter(c => this._patchNumEquals(c.patch_set, opt_patchNum) ); } /** * Get the comments (robot comments) for a file. * * // TODO(taoalpha): maybe merge in *ForPath * * @param {!{path: string, oldPath?: string, patchNum?: number}} file * @param {boolean=} opt_includeDrafts * @return {!Array} */ getAllCommentsForFile(file, opt_includeDrafts) { let allComments = this.getAllCommentsForPath( file.path, file.patchNum, opt_includeDrafts ); if (file.oldPath) { allComments = allComments.concat( this.getAllCommentsForPath( file.oldPath, file.patchNum, opt_includeDrafts ) ); } return allComments; } /** * Get the drafts for a path and optional patch num. * * @param {!string} path * @param {number=} opt_patchNum * @return {!Array} */ getAllDraftsForPath(path, opt_patchNum) { const comments = this._drafts[path] || []; if (!opt_patchNum) { return comments; } return (comments || []).filter(c => this._patchNumEquals(c.patch_set, opt_patchNum) ); } /** * Get the drafts for a file. * * // TODO(taoalpha): maybe merge in *ForPath * * @param {!{path: string, oldPath?: string, patchNum?: number}} file * @return {!Array} */ getAllDraftsForFile(file) { let allDrafts = this.getAllDraftsForPath(file.path, file.patchNum); if (file.oldPath) { allDrafts = allDrafts.concat( this.getAllDraftsForPath(file.oldPath, file.patchNum) ); } return allDrafts; } /** * Get the comments (with drafts and robot comments) for a path and * patch-range. Returns an object with left and right properties mapping to * arrays of comments in on either side of the patch range for that path. * * @param {!string} path * @param {!Gerrit.PatchRange} patchRange The patch-range object containing patchNum * and basePatchNum properties to represent the range. * @param {Object=} opt_projectConfig Optional project config object to * include in the meta sub-object. * @return {!Gerrit.CommentsBySide} */ getCommentsBySideForPath(path, patchRange, opt_projectConfig) { let comments = []; let drafts = []; let robotComments = []; if (this.comments && this.comments[path]) { comments = this.comments[path]; } if (this.drafts && this.drafts[path]) { drafts = this.drafts[path]; } if (this.robotComments && this.robotComments[path]) { robotComments = this.robotComments[path]; } drafts.forEach(d => { d.__draft = true; }); const all = comments.concat(drafts).concat(robotComments); const baseComments = all.filter(c => this._isInBaseOfPatchRange(c, patchRange)); const revisionComments = all.filter(c => this._isInRevisionOfPatchRange(c, patchRange)); return { meta: { changeNum: this._changeNum, path, patchRange, projectConfig: opt_projectConfig, }, left: baseComments, right: revisionComments, }; } /** * Get the comments (with drafts and robot comments) for a file and * patch-range. Returns an object with left and right properties mapping to * arrays of comments in on either side of the patch range for that path. * * // TODO(taoalpha): maybe merge *ForPath so find all comments in one pass * * @param {!{path: string, oldPath?: string, patchNum?: number}} file * @param {!Gerrit.PatchRange} patchRange The patch-range object containing patchNum * and basePatchNum properties to represent the range. * @param {Object=} opt_projectConfig Optional project config object to * include in the meta sub-object. * @return {!Gerrit.CommentsBySide} */ getCommentsBySideForFile(file, patchRange, opt_projectConfig) { const comments = this.getCommentsBySideForPath( file.path, patchRange, opt_projectConfig ); if (file.oldPath) { const commentsForOldPath = this.getCommentsBySideForPath( file.oldPath, patchRange, opt_projectConfig ); // merge in the left and right comments.left = comments.left.concat(commentsForOldPath.left); comments.right = comments.right.concat(commentsForOldPath.right); } } /** * @param {!Object} comments Object keyed by file, with a value of an array * of comments left on that file. * @return {!Array} A flattened list of all comments, where each comment * also includes the file that it was left on, which was the key of the * originall object. */ _commentObjToArrayWithFile(comments) { let commentArr = []; for (const file of Object.keys(comments)) { const commentsForFile = []; for (const comment of comments[file]) { commentsForFile.push(Object.assign({__path: file}, comment)); } commentArr = commentArr.concat(commentsForFile); } return commentArr; } _commentObjToArray(comments) { let commentArr = []; for (const file of Object.keys(comments)) { commentArr = commentArr.concat(comments[file]); } return commentArr; } /** * Computes a string counting the number of commens in a given file. * * @param {{path: string, oldPath?: string, patchNum?: number}} file * @return {number} */ computeCommentCount(file) { if (file.path) { return this.getAllCommentsForFile(file).length; } const allComments = this.getAllPublishedComments(file.patchNum); return this._commentObjToArray(allComments).length; } /** * Computes a string counting the number of draft comments in the entire * change, optionally filtered by path and/or patchNum. * * @param {?{path: string, oldPath?: string, patchNum?: number}} file * @return {number} */ computeDraftCount(file) { if (file && file.path) { return this.getAllDraftsForFile(file).length; } const allDrafts = this.getAllDrafts(file && file.patchNum); return this._commentObjToArray(allDrafts).length; } /** * Computes a number of unresolved comment threads in a given file and path. * * @param {{path: string, oldPath?: string, patchNum?: number}} file * @return {number} */ computeUnresolvedNum(file) { let comments = []; let drafts = []; if (file.path) { comments = this.getAllCommentsForFile(file); drafts = this.getAllDraftsForFile(file); } else { comments = this._commentObjToArray( this.getAllPublishedComments(file.patchNum)); } comments = comments.concat(drafts); const threads = this.getCommentThreads(this._sortComments(comments)); const unresolvedThreads = threads .filter(thread => thread.comments.length && thread.comments[thread.comments.length - 1].unresolved); return unresolvedThreads.length; } getAllThreadsForChange() { const comments = this._commentObjToArrayWithFile(this.getAllComments(true)); const sortedComments = this._sortComments(comments); return this.getCommentThreads(sortedComments); } _sortComments(comments) { return comments.slice(0) .sort( (c1, c2) => util.parseDate(c1.updated) - util.parseDate(c2.updated) ); } /** * Computes all of the comments in thread format. * * @param {!Array} comments sorted by updated timestamp. * @return {!Array} */ getCommentThreads(comments) { const threads = []; const idThreadMap = {}; for (const comment of comments) { // If the comment is in reply to another comment, find that comment's // thread and append to it. if (comment.in_reply_to) { const thread = idThreadMap[comment.in_reply_to]; if (thread) { thread.comments.push(comment); idThreadMap[comment.id] = thread; continue; } } // Otherwise, this comment starts its own thread. const newThread = { comments: [comment], patchNum: comment.patch_set, path: comment.__path, line: comment.line, rootId: comment.id, }; if (comment.side) { newThread.commentSide = comment.side; } threads.push(newThread); idThreadMap[comment.id] = newThread; } return threads; } /** * Whether the given comment should be included in the base side of the * given patch range. * * @param {!Object} comment * @param {!Gerrit.PatchRange} range * @return {boolean} */ _isInBaseOfPatchRange(comment, range) { // If the base of the patch range is a parent of a merge, and the comment // appears on a specific parent then only show the comment if the parent // index of the comment matches that of the range. if (comment.parent && comment.side === PARENT) { return this._isMergeParent(range.basePatchNum) && comment.parent === this._getParentIndex(range.basePatchNum); } // If the base of the range is the parent of the patch: if (range.basePatchNum === PARENT && comment.side === PARENT && this._patchNumEquals(comment.patch_set, range.patchNum)) { return true; } // If the base of the range is not the parent of the patch: if (range.basePatchNum !== PARENT && comment.side !== PARENT && this._patchNumEquals(comment.patch_set, range.basePatchNum)) { return true; } return false; } /** * Whether the given comment should be included in the revision side of the * given patch range. * * @param {!Object} comment * @param {!Gerrit.PatchRange} range * @return {boolean} */ _isInRevisionOfPatchRange(comment, range) { return comment.side !== PARENT && this._patchNumEquals(comment.patch_set, range.patchNum); } /** * Whether the given comment should be included in the given patch range. * * @param {!Object} comment * @param {!Gerrit.PatchRange} range * @return {boolean|undefined} */ _isInPatchRange(comment, range) { return this._isInBaseOfPatchRange(comment, range) || this._isInRevisionOfPatchRange(comment, range); } } /** * @appliesMixin Gerrit.PatchSetMixin * @extends Polymer.Element */ class GrCommentApi extends mixinBehaviors( [ Gerrit.PatchSetBehavior, ], GestureEventListeners( LegacyElementMixin( PolymerElement))) { static get template() { return htmlTemplate; } static get is() { return 'gr-comment-api'; } static get properties() { return { _changeComments: Object, }; } /** @override */ created() { super.created(); this.addEventListener('reload-drafts', changeNum => this.reloadDrafts(changeNum)); } /** * Load all comments (with drafts and robot comments) for the given change * number. The returned promise resolves when the comments have loaded, but * does not yield the comment data. * * @param {number} changeNum * @return {!Promise} */ loadAll(changeNum) { const promises = []; promises.push(this.$.restAPI.getDiffComments(changeNum)); promises.push(this.$.restAPI.getDiffRobotComments(changeNum)); promises.push(this.$.restAPI.getDiffDrafts(changeNum)); return Promise.all(promises).then(([comments, robotComments, drafts]) => { this._changeComments = new ChangeComments(comments, robotComments, drafts, changeNum); return this._changeComments; }); } /** * Re-initialize _changeComments with a new ChangeComments object, that * uses the previous values for comments and robot comments, but fetches * updated draft comments. * * @param {number} changeNum * @return {!Promise} */ reloadDrafts(changeNum) { if (!this._changeComments) { return this.loadAll(changeNum); } return this.$.restAPI.getDiffDrafts(changeNum).then(drafts => { this._changeComments = new ChangeComments(this._changeComments.comments, this._changeComments.robotComments, drafts, changeNum); return this._changeComments; }); } } customElements.define(GrCommentApi.is, GrCommentApi);