blob: b60a5857bb88e3a3ca8a9b56784be469c19706a7 [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 {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
import {PolymerElement} from '@polymer/polymer/polymer-element';
import {htmlTemplate} from './gr-comment-api_html';
import {CURRENT} from '../../../utils/patch-set-util';
import {customElement, property} from '@polymer/decorators';
import {
CommentBasics,
PatchRange,
PatchSetNum,
PathToRobotCommentsInfoMap,
RobotCommentInfo,
UrlEncodedCommentId,
NumericChangeId,
PathToCommentsInfoMap,
FileInfo,
ParentPatchSetNum,
} from '../../../types/common';
import {
Comment,
CommentMap,
CommentThread,
DraftInfo,
isUnresolved,
UIComment,
UIDraft,
UIHuman,
UIRobot,
createCommentThreads,
isInPatchRange,
isDraftThread,
isInBaseOfPatchRange,
isInRevisionOfPatchRange,
} from '../../../utils/comment-util';
import {PatchSetFile, PatchNumOnly, isPatchSetFile} from '../../../types/types';
import {appContext} from '../../../services/app-context';
import {CommentSide, Side} from '../../../constants/constants';
import {KnownExperimentId} from '../../../services/flags/flags';
import {pluralize} from '../../../utils/string-util';
export type CommentIdToCommentThreadMap = {
[urlEncodedCommentId: string]: CommentThread;
};
export class ChangeComments {
private readonly _comments: {[path: string]: UIHuman[]};
private readonly _robotComments: {[path: string]: UIRobot[]};
private readonly _drafts: {[path: string]: UIDraft[]};
private readonly _portedComments: PathToCommentsInfoMap;
private readonly _portedDrafts: PathToCommentsInfoMap;
/**
* Construct a change comments object, which can be data-bound to child
* elements of that which uses the gr-comment-api.
*/
constructor(
comments: {[path: string]: UIHuman[]} | undefined,
robotComments: {[path: string]: UIRobot[]} | undefined,
drafts: {[path: string]: UIDraft[]} | undefined,
portedComments: PathToCommentsInfoMap | undefined,
portedDrafts: PathToCommentsInfoMap | undefined
) {
this._comments = this._addPath(comments);
this._robotComments = this._addPath(robotComments);
this._drafts = this._addPath(drafts);
this._portedComments = portedComments || {};
this._portedDrafts = portedDrafts || {};
}
/**
* Add path info to every comment as CommentInfo returned
* from server does not have that.
*
* TODO(taoalpha): should consider changing BE to send path
* back within CommentInfo
*/
_addPath<T>(
comments: {[path: string]: T[]} = {}
): {[path: string]: Array<T & {path: string}>} {
const updatedComments: {[path: string]: Array<T & {path: string}>} = {};
for (const filePath of Object.keys(comments)) {
const allCommentsForPath = comments[filePath] || [];
if (allCommentsForPath.length) {
updatedComments[filePath] = allCommentsForPath.map(comment => {
return {...comment, path: filePath};
});
}
}
return updatedComments;
}
get drafts() {
return this._drafts;
}
findCommentById(commentId?: UrlEncodedCommentId): UIComment | undefined {
if (!commentId) return undefined;
const findComment = (comments: {[path: string]: UIComment[]}) => {
let comment;
for (const path of Object.keys(comments)) {
comment = comment || comments[path].find(c => c.id === commentId);
}
return comment;
};
return (
findComment(this._comments) ||
findComment(this._robotComments) ||
findComment(this._drafts)
);
}
/**
* 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 patchRange The patch-range object containing
* patchNum and basePatchNum properties to represent the range.
*/
getPaths(patchRange?: PatchRange): CommentMap {
const responses: {[path: string]: UIComment[]}[] = [
this._comments,
this.drafts,
this._robotComments,
];
const commentMap: CommentMap = {};
for (const response of responses) {
for (const [path, comments] of Object.entries(response)) {
if (
comments.some(c => {
// If don't care about patch range, we know that the path exists.
return !patchRange || isInPatchRange(c, patchRange);
})
) {
commentMap[path] = true;
}
}
}
return commentMap;
}
/**
* Gets all the comments and robot comments for the given change.
*/
getAllPublishedComments(patchNum?: PatchSetNum) {
return this.getAllComments(false, patchNum);
}
/**
* Gets all the comments for a particular thread group. Used for refreshing
* comments after the thread group has already been built.
*/
getCommentsForThread(rootId: UrlEncodedCommentId) {
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;
}
/**
* Gets all the comments and robot comments for the given change.
*/
getAllComments(includeDrafts?: boolean, patchNum?: PatchSetNum) {
const paths = this.getPaths();
const publishedComments: {[path: string]: CommentBasics[]} = {};
for (const path of Object.keys(paths)) {
publishedComments[path] = this.getAllCommentsForPath(
path,
patchNum,
includeDrafts
);
}
return publishedComments;
}
/**
* Gets all the drafts for the given change.
*/
getAllDrafts(patchNum?: PatchSetNum) {
const paths = this.getPaths();
const drafts: {[path: string]: UIDraft[]} = {};
for (const path of Object.keys(paths)) {
drafts[path] = this.getAllDraftsForPath(path, patchNum);
}
return drafts;
}
/**
* Get the comments (robot comments) for a path and optional patch num.
*
* This method will always return a new shallow copy of all comments,
* so manipulation on one copy won't affect other copies.
*
*/
getAllCommentsForPath(
path: string,
patchNum?: PatchSetNum,
includeDrafts?: boolean
): Comment[] {
const comments: Comment[] = this._comments[path] || [];
const robotComments = this._robotComments[path] || [];
let allComments = comments.concat(robotComments);
if (includeDrafts) {
const drafts = this.getAllDraftsForPath(path);
allComments = allComments.concat(drafts);
}
if (patchNum) {
allComments = allComments.filter(c => c.patch_set === patchNum);
}
return allComments.map(c => {
return {...c};
});
}
/**
* Get the comments (robot comments) for a file.
*
* // TODO(taoalpha): maybe merge in *ForPath
*/
getAllCommentsForFile(file: PatchSetFile, includeDrafts?: boolean) {
let allComments = this.getAllCommentsForPath(
file.path,
file.patchNum,
includeDrafts
);
if (file.basePath) {
allComments = allComments.concat(
this.getAllCommentsForPath(file.basePath, file.patchNum, includeDrafts)
);
}
return allComments;
}
cloneWithUpdatedDrafts(drafts: {[path: string]: UIDraft[]} | undefined) {
return new ChangeComments(
this._comments,
this._robotComments,
drafts,
this._portedComments,
this._portedDrafts
);
}
cloneWithUpdatedPortedComments(
portedComments?: PathToCommentsInfoMap,
portedDrafts?: PathToCommentsInfoMap
) {
return new ChangeComments(
this._comments,
this._robotComments,
this._drafts,
portedComments,
portedDrafts
);
}
/**
* Get the drafts for a path and optional patch num.
*
* This will return a shallow copy of all drafts every time,
* so changes on any copy will not affect other copies.
*/
getAllDraftsForPath(path: string, patchNum?: PatchSetNum): Comment[] {
let comments = this._drafts[path] || [];
if (patchNum) {
comments = comments.filter(c => c.patch_set === patchNum);
}
return comments.map(c => {
return {...c, __draft: true};
});
}
/**
* Get the drafts for a file.
*
* // TODO(taoalpha): maybe merge in *ForPath
*/
getAllDraftsForFile(file: PatchSetFile): Comment[] {
let allDrafts = this.getAllDraftsForPath(file.path, file.patchNum);
if (file.basePath) {
allDrafts = allDrafts.concat(
this.getAllDraftsForPath(file.basePath, 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 patchRange The patch-range object containing patchNum
* and basePatchNum properties to represent the range.
* @param projectConfig Optional project config object to
* include in the meta sub-object.
*/
getCommentsForPath(path: string, patchRange: PatchRange): Comment[] {
let comments: Comment[] = [];
let drafts: DraftInfo[] = [];
let robotComments: RobotCommentInfo[] = [];
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;
});
return comments
.concat(drafts)
.concat(robotComments)
.filter(c => isInPatchRange(c, patchRange))
.map(c => {
return {...c};
});
}
/**
* Get the ported threads for given patch range.
* Ported threads are comment threads that were posted on an older patchset
* and are displayed on a later patchset.
* It is simply the original thread displayed on a newer patchset.
*
* Threads are ported over to all subsequent patchsets. So, a thread created
* on patchset 5 say will be ported over to patchsets 6,7,8 and beyond.
*
* Ported threads add a boolean property ported true to the thread object
* to indicate to the user that this is a ported thread.
*
* Any interactions with ported threads are reflected on the original threads.
* Replying to a ported thread ported from Patchset 6 shown on Patchset 10
* say creates a draft reply associated with Patchset 6, since the user is
* interacting with the original thread.
*
* Only threads with unresolved comments or drafts are ported over.
* If the thread is associated with either the left patchset or the right
* patchset, then we filter that ported thread from the return value
* as it will be rendered by default.
*
* If there is no appropriate range for the ported comments, then the backend
* does not return the range of the ported thread and it becomes a file level
* thread.
*
* If a comment was created with Side=PARENT, then we only show this ported
* comment if Base is part of the patch range, always on the left side of
* the diff.
*
* @return only the ported threads for the specified file and patch range
*/
_getPortedCommentThreads(
file: PatchSetFile,
patchRange: PatchRange
): CommentThread[] {
const portedComments = this._portedComments[file.path] || [];
portedComments.push(...(this._portedDrafts[file.path] || []));
if (file.basePath) {
portedComments.push(...(this._portedComments[file.basePath] || []));
portedComments.push(...(this._portedDrafts[file.basePath] || []));
}
if (!portedComments.length) return [];
// when forming threads in diff view, we filter for current patchrange but
// ported comments will involve comments that may not belong to the
// current patchrange, so we need to form threads for them using all
// comments
const allComments: UIComment[] = this.getAllCommentsForFile(file, true);
return createCommentThreads(allComments).filter(thread => {
// Robot comments and drafts are not ported over. A human reply to
// the robot comment will be ported over, thefore it's possible to
// have the root comment of the thread not be ported, hence loop over
// entire thread
const portedComment = portedComments.find(portedComment =>
thread.comments.some(c => portedComment.id === c.id)
);
if (!portedComment) return false;
const originalComment = thread.comments.find(
comment => comment.id === portedComment.id
)!;
if (
(originalComment.line && !portedComment.line) ||
(originalComment.range && !portedComment.range)
) {
thread.rangeInfoLost = true;
}
if (
isInBaseOfPatchRange(thread.comments[0], patchRange) ||
isInRevisionOfPatchRange(thread.comments[0], patchRange)
) {
// no need to port this thread as it will be rendered by default
return false;
}
thread.diffSide = Side.RIGHT;
if (thread.commentSide === CommentSide.PARENT) {
// TODO(dhruvsri): Add handling for merge parents
if (
patchRange.basePatchNum !== ParentPatchSetNum ||
!!thread.mergeParentNum
)
return false;
thread.diffSide = Side.LEFT;
}
if (!isUnresolved(thread) && !isDraftThread(thread)) return false;
thread.range = portedComment.range;
thread.line = portedComment.line;
thread.ported = true;
return true;
});
}
getThreadsBySideForFile(
file: PatchSetFile,
patchRange: PatchRange
): CommentThread[] {
const threads = createCommentThreads(
this.getCommentsForFile(file, patchRange),
patchRange
);
threads.push(...this._getPortedCommentThreads(file, patchRange));
return threads;
}
/**
* 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 patchRange The patch-range object containing patchNum
* and basePatchNum properties to represent the range.
* @param projectConfig Optional project config object to
* include in the meta sub-object.
*/
getCommentsForFile(file: PatchSetFile, patchRange: PatchRange): Comment[] {
const comments = this.getCommentsForPath(file.path, patchRange);
if (file.basePath) {
comments.push(...this.getCommentsForPath(file.basePath, patchRange));
}
return comments;
}
_commentObjToArray<T>(comments: {[path: string]: T[]}): T[] {
return Object.keys(comments).reduce((commentArr: T[], file) => {
comments[file].forEach(c => commentArr.push({...c}));
return commentArr;
}, []);
}
/**
* Computes the number of comment threads in a given file or patch.
*/
computeCommentThreadCount(file: PatchSetFile | PatchNumOnly) {
let comments: Comment[] = [];
if (isPatchSetFile(file)) {
comments = this.getAllCommentsForFile(file);
} else {
comments = this._commentObjToArray(
this.getAllPublishedComments(file.patchNum)
);
}
return createCommentThreads(comments).length;
}
/**
* Computes a string counting the number of draft comments in the entire
* change, optionally filtered by path and/or patchNum.
*/
computeDraftCount(file?: PatchSetFile | PatchNumOnly) {
if (file && isPatchSetFile(file)) {
return this.getAllDraftsForFile(file).length;
}
const allDrafts = this.getAllDrafts(file && file.patchNum);
return this._commentObjToArray(allDrafts).length;
}
// TODO(dhruvsri): merge with computeDraftCount
computePortedDraftCount(patchRange: PatchRange, path: string) {
const threads = this.getThreadsBySideForFile({path}, patchRange);
return threads.filter(thread => isDraftThread(thread) && thread.ported)
.length;
}
/**
* @param includeUnmodified Included unmodified status of the file in the
* comment string or not. For files we opt of chip instead of a string.
* @param filterPatchset Only count threads which belong to this patchset
*/
computeCommentsString(
patchRange?: PatchRange,
path?: string,
changeFileInfo?: FileInfo,
includeUnmodified?: boolean
) {
if (!path) return '';
if (!patchRange) return '';
const threads = this.getThreadsBySideForFile({path}, patchRange);
const commentThreadCount = threads.filter(thread => !isDraftThread(thread))
.length;
const unresolvedCount = threads.reduce((cnt, thread) => {
if (isUnresolved(thread)) cnt += 1;
return cnt;
}, 0);
const commentThreadString = pluralize(commentThreadCount, 'comment');
const unresolvedString =
unresolvedCount === 0 ? '' : `${unresolvedCount} unresolved`;
const unmodifiedString =
includeUnmodified && changeFileInfo?.status === 'U' ? 'no changes' : '';
return (
commentThreadString +
// Add a space if both comments and unresolved
(commentThreadString && unresolvedString ? ' ' : '') +
// Add parentheses around unresolved if it exists.
(unresolvedString ? `(${unresolvedString})` : '') +
(unmodifiedString ? `(${unmodifiedString})` : '')
);
}
/**
* Computes a number of unresolved comment threads in a given file and path.
*/
computeUnresolvedNum(file: PatchSetFile | PatchNumOnly) {
let comments: Comment[] = [];
let drafts: Comment[] = [];
if (isPatchSetFile(file)) {
comments = this.getAllCommentsForFile(file);
drafts = this.getAllDraftsForFile(file);
} else {
comments = this._commentObjToArray(
this.getAllPublishedComments(file.patchNum)
);
}
comments = comments.concat(drafts);
const threads = createCommentThreads(comments);
const unresolvedThreads = threads.filter(isUnresolved);
return unresolvedThreads.length;
}
getAllThreadsForChange() {
const comments = this._commentObjToArray(this.getAllComments(true));
return createCommentThreads(comments);
}
}
// TODO(TS): move findCommentById out of class
export const _testOnly_findCommentById =
ChangeComments.prototype.findCommentById;
export const _testOnly_getCommentsForPath =
ChangeComments.prototype.getCommentsForPath;
@customElement('gr-comment-api')
export class GrCommentApi extends GestureEventListeners(
LegacyElementMixin(PolymerElement)
) {
static get template() {
return htmlTemplate;
}
@property({type: Object})
_changeComments?: ChangeComments;
private readonly restApiService = appContext.restApiService;
private readonly flagsService = appContext.flagsService;
private isPortingCommentsExperimentEnabled = false;
/** @override */
created() {
super.created();
}
constructor() {
super();
this.isPortingCommentsExperimentEnabled = this.flagsService.isEnabled(
KnownExperimentId.PORTING_COMMENTS
);
}
/**
* 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.
*/
loadAll(changeNum: NumericChangeId, patchNum?: PatchSetNum) {
const revision = patchNum || CURRENT;
const commentsPromise = [
this.restApiService.getDiffComments(changeNum),
this.restApiService.getDiffRobotComments(changeNum),
this.restApiService.getDiffDrafts(changeNum),
this.isPortingCommentsExperimentEnabled
? this.restApiService.getPortedComments(changeNum, revision)
: Promise.resolve({}),
this.isPortingCommentsExperimentEnabled
? this.restApiService.getPortedDrafts(changeNum, revision)
: Promise.resolve({}),
];
return Promise.all(commentsPromise).then(
([comments, robotComments, drafts, portedComments, portedDrafts]) => {
this._changeComments = new ChangeComments(
comments,
// TS 4.0.5 fails without 'as'
robotComments as PathToRobotCommentsInfoMap | undefined,
drafts,
portedComments,
portedDrafts
);
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.
*/
reloadDrafts(changeNum: NumericChangeId) {
if (!this._changeComments) {
return this.loadAll(changeNum);
}
return this.restApiService.getDiffDrafts(changeNum).then(drafts => {
this._changeComments = this._changeComments!.cloneWithUpdatedDrafts(
drafts
);
return this._changeComments;
});
}
reloadPortedComments(changeNum: NumericChangeId, patchNum: PatchSetNum) {
if (!this._changeComments) {
this.loadAll(changeNum);
return Promise.resolve();
}
return Promise.all([
this.restApiService.getPortedComments(changeNum, patchNum),
this.restApiService.getPortedDrafts(changeNum, patchNum),
]).then(res => {
if (!this._changeComments) return;
this._changeComments = this._changeComments.cloneWithUpdatedPortedComments(
res[0],
res[1]
);
});
}
}
declare global {
interface HTMLElementTagNameMap {
'gr-comment-api': GrCommentApi;
}
}