| /** |
| * @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 {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 {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 PolymerElement { |
| static get template() { |
| return htmlTemplate; |
| } |
| |
| @property({type: Object}) |
| _changeComments?: ChangeComments; |
| |
| private readonly restApiService = appContext.restApiService; |
| |
| /** |
| * 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.restApiService.getPortedComments(changeNum, revision), |
| this.restApiService.getPortedDrafts(changeNum, revision), |
| ]; |
| |
| 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; |
| } |
| } |