blob: 3f7da5a5e4189ccc346b94e5b78b8a57829ee226 [file] [log] [blame]
/**
* @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);