| /** |
| * @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 '../../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'; |
| import {PatchSetBehavior} from '../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.js'; |
| import {util} from '../../../scripts/util.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 = |
| PatchSetBehavior.patchNumEquals; |
| this._isMergeParent = |
| PatchSetBehavior.isMergeParent; |
| this._getParentIndex = |
| 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); |
| } |
| return comments; |
| } |
| |
| /** |
| * @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) => { |
| const dateDiff = |
| util.parseDate(c1.updated) - util.parseDate(c2.updated); |
| if (dateDiff) { |
| return dateDiff; |
| } |
| return c1.id - c2.id; |
| } |
| ); |
| } |
| |
| /** |
| * 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); |
| } |
| } |
| |
| /** |
| * @extends Polymer.Element |
| */ |
| class GrCommentApi extends mixinBehaviors( [ |
| 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<!Object>} |
| */ |
| 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<!Object>} |
| */ |
| 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); |