blob: 37491d265aebf3c4c35a8a497ec149079c18950c [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.
*/
(function() {
'use strict';
const PARENT = 'PARENT';
const Defs = {};
/**
* @typedef {{
* basePatchNum: (string|number),
* patchNum: (number),
* }}
*/
Defs.patchRange;
/**
* @typedef {{
* changeNum: number,
* path: string,
* patchRange: !Defs.patchRange,
* projectConfig: (Object|undefined),
* }}
*/
Defs.commentMeta;
/**
* @typedef {{
* meta: !Defs.commentMeta,
* left: !Array,
* right: !Array,
* }}
*/
Defs.commentsBySide;
/**
* Construct a change comments object, which can be data-bound to child
* elements of that which uses the gr-comment-api.
*
* @param {!Object} comments
* @param {!Object} robotComments
* @param {!Object} drafts
* @param {number} changeNum
* @constructor
*/
function ChangeComments(comments, robotComments, drafts, changeNum) {
this._comments = comments;
this._robotComments = robotComments;
this._drafts = drafts;
this._changeNum = changeNum;
}
ChangeComments.prototype = {
get comments() {
return this._comments;
},
get drafts() {
return this._drafts;
},
get robotComments() {
return this._robotComments;
},
};
ChangeComments.prototype._patchNumEquals =
Gerrit.PatchSetBehavior.patchNumEquals;
ChangeComments.prototype._isMergeParent =
Gerrit.PatchSetBehavior.isMergeParent;
ChangeComments.prototype._getParentIndex =
Gerrit.PatchSetBehavior.getParentIndex;
/**
* 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 {Defs.patchRange=} opt_patchRange The patch-range object containing
* patchNum and basePatchNum properties to represent the range.
* @return {!Object}
*/
ChangeComments.prototype.getPaths = function(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}
*/
ChangeComments.prototype.getAllPublishedComments = function(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
*/
ChangeComments.prototype.getCommentsForThread = function(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
*/
ChangeComments.prototype._filterCommentsBySideAndLine = function(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}
*/
ChangeComments.prototype.getAllComments = function(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}
*/
ChangeComments.prototype.getAllDrafts = function(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}
*/
ChangeComments.prototype.getAllCommentsForPath = function(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 drafts for a path and optional patch num.
*
* @param {!string} path
* @param {number=} opt_patchNum
* @return {!Array}
*/
ChangeComments.prototype.getAllDraftsForPath = function(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 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 {!Defs.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 {!Defs.commentsBySide}
*/
ChangeComments.prototype.getCommentsBySideForPath = function(path,
patchRange, opt_projectConfig) {
const comments = this.comments[path] || [];
const drafts = this.drafts[path] || [];
const 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,
};
};
/**
* @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.
*/
ChangeComments.prototype._commentObjToArrayWithFile = function(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;
};
ChangeComments.prototype._commentObjToArray = function(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 and path.
*
* @param {number} patchNum
* @param {string=} opt_path
* @return {number}
*/
ChangeComments.prototype.computeCommentCount = function(patchNum, opt_path) {
if (opt_path) {
return this.getAllCommentsForPath(opt_path, patchNum).length;
}
const allComments = this.getAllPublishedComments(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 {number=} opt_patchNum
* @param {string=} opt_path
* @return {number}
*/
ChangeComments.prototype.computeDraftCount = function(opt_patchNum,
opt_path) {
if (opt_path) {
return this.getAllDraftsForPath(opt_path, opt_patchNum).length;
}
const allDrafts = this.getAllDrafts(opt_patchNum);
return this._commentObjToArray(allDrafts).length;
};
/**
* Computes a number of unresolved comment threads in a given file and path.
*
* @param {number} patchNum
* @param {string=} opt_path
* @return {number}
*/
ChangeComments.prototype.computeUnresolvedNum = function(patchNum,
opt_path) {
let comments = [];
let drafts = [];
if (opt_path) {
comments = this.getAllCommentsForPath(opt_path, patchNum);
drafts = this.getAllDraftsForPath(opt_path, patchNum);
} else {
comments = this._commentObjToArray(
this.getAllPublishedComments(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;
};
ChangeComments.prototype.getAllThreadsForChange = function() {
const comments = this._commentObjToArrayWithFile(this.getAllComments(true));
const sortedComments = this._sortComments(comments);
return this.getCommentThreads(sortedComments);
};
ChangeComments.prototype._sortComments = function(comments) {
return comments.slice(0).sort((c1, c2) => {
return 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}
*/
ChangeComments.prototype.getCommentThreads = function(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 {!Defs.patchRange} range
* @return {boolean}
*/
ChangeComments.prototype._isInBaseOfPatchRange = function(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 {!Defs.patchRange} range
* @return {boolean}
*/
ChangeComments.prototype._isInRevisionOfPatchRange = function(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 {!Defs.patchRange} range
* @return {boolean|undefined}
*/
ChangeComments.prototype._isInPatchRange = function(comment, range) {
return this._isInBaseOfPatchRange(comment, range) ||
this._isInRevisionOfPatchRange(comment, range);
};
Polymer({
is: 'gr-comment-api',
_legacyUndefinedCheck: true,
properties: {
_changeComments: Object,
},
listeners: {
'reload-drafts': 'reloadDrafts',
},
behaviors: [
Gerrit.PatchSetBehavior,
],
/**
* 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;
});
},
});
})();