| /** | |
| * @license | |
| * Copyright 2017 Google LLC | |
| * SPDX-License-Identifier: Apache-2.0 | |
| */ | |
| import { | |
| PatchRange, | |
| PatchSetNum, | |
| RobotCommentInfo, | |
| FileInfo, | |
| PARENT, | |
| CommentThread, | |
| Comment, | |
| CommentMap, | |
| DraftInfo, | |
| CommentInfo, | |
| } from '../../../types/common'; | |
| import { | |
| isUnresolved, | |
| createCommentThreads, | |
| isInPatchRange, | |
| isDraftThread, | |
| isPatchsetLevel, | |
| addPath, | |
| id, | |
| } from '../../../utils/comment-util'; | |
| import {PatchSetFile, PatchNumOnly, isPatchSetFile} from '../../../types/types'; | |
| import {CommentSide} from '../../../constants/constants'; | |
| import {pluralize} from '../../../utils/string-util'; | |
| import {NormalizedFileInfo} from '../../change/gr-file-list/gr-file-list'; | |
| // TODO: Move file out of elements/ directory | |
| export class ChangeComments { | |
| private readonly _comments: {[path: string]: CommentInfo[]}; | |
| private readonly _robotComments: {[path: string]: RobotCommentInfo[]}; | |
| private readonly _drafts: {[path: string]: DraftInfo[]}; | |
| private readonly _portedComments: {[path: string]: CommentInfo[]}; | |
| private readonly _portedDrafts: {[path: string]: DraftInfo[]}; | |
| constructor( | |
| comments?: {[path: string]: CommentInfo[]}, | |
| robotComments?: {[path: string]: RobotCommentInfo[]}, | |
| drafts?: {[path: string]: DraftInfo[]}, | |
| portedComments?: {[path: string]: CommentInfo[]}, | |
| portedDrafts?: {[path: string]: DraftInfo[]} | |
| ) { | |
| this._comments = addPath(comments); | |
| this._robotComments = addPath(robotComments); | |
| this._drafts = addPath(drafts); | |
| this._portedComments = portedComments || {}; | |
| this._portedDrafts = portedDrafts || {}; | |
| } | |
| get drafts() { | |
| return 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]: Comment[]}[] = [ | |
| this._comments, | |
| this.drafts, | |
| this._robotComments, | |
| ]; | |
| const commentMap: CommentMap = {}; | |
| for (const response of responses) { | |
| for (const [path, comments] of Object.entries(response)) { | |
| // If don't care about patch range, we know that the path exists. | |
| if (comments.some(c => !patchRange || isInPatchRange(c, patchRange))) { | |
| // TODO: Replace the CommentMap type with just an array or set. We | |
| // never set the value to false. | |
| 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 and robot comments for the given change. | |
| */ | |
| getAllComments(includeDrafts?: boolean, patchNum?: PatchSetNum) { | |
| const paths = this.getPaths(); | |
| const publishedComments: {[path: string]: Comment[]} = {}; | |
| 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]: DraftInfo[]} = {}; | |
| 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; | |
| } | |
| /** | |
| * 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): DraftInfo[] { | |
| let drafts = this._drafts[path] || []; | |
| if (patchNum) { | |
| drafts = drafts.filter(c => c.patch_set === patchNum); | |
| } | |
| return drafts; | |
| } | |
| /** | |
| * Get the drafts for a file. | |
| * | |
| * // TODO(taoalpha): maybe merge in *ForPath | |
| */ | |
| getAllDraftsForFile(file: PatchSetFile): DraftInfo[] { | |
| 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 array containing comments from either side of the | |
| * patch range for that path. | |
| * | |
| * @param patchRange The patch-range object containing patchNum and | |
| * basePatchNum properties to represent the range. | |
| */ | |
| 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]; | |
| } | |
| const all = comments.concat(drafts).concat(robotComments); | |
| const final = all | |
| .filter(c => isInPatchRange(c, patchRange)) | |
| .map(c => { | |
| return {...c}; | |
| }); | |
| return final; | |
| } | |
| /** | |
| * 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: Comment[] = 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: Comment[] = 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, therefore 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 => id(portedComment) === id(c)) | |
| ); | |
| if (!portedComment) return false; | |
| const originalComment = thread.comments.find( | |
| comment => id(comment) === id(portedComment) | |
| )!; | |
| // Original comment shown anyway? No need to port. | |
| if (isInPatchRange(originalComment, patchRange)) return false; | |
| if (thread.commentSide === CommentSide.PARENT) { | |
| // TODO(dhruvsri): Add handling for merge parents | |
| if (patchRange.basePatchNum !== PARENT || !!thread.mergeParentNum) | |
| return false; | |
| } | |
| if (!isUnresolved(thread) && !isDraftThread(thread)) return false; | |
| if ( | |
| (originalComment.line && !portedComment.line) || | |
| (originalComment.range && !portedComment.range) | |
| ) { | |
| thread.rangeInfoLost = true; | |
| } | |
| // TODO: It probably makes more sense to set the patch_set in | |
| // portedComment either in the backend or in the RestApi layer. Then we | |
| // could check `!isInPatchRange(portedComment, patchRange)` and then set | |
| // thread.patchNum = portedComment.patch_set; | |
| thread.patchNum = patchRange.patchNum; | |
| 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) | |
| ); | |
| 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. | |
| */ | |
| 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 comment threads in a given file or patch. | |
| */ | |
| computeCommentThreads( | |
| file: PatchSetFile | PatchNumOnly, | |
| ignorePatchsetLevelComments?: boolean | |
| ) { | |
| let comments: Comment[] = []; | |
| if (isPatchSetFile(file)) { | |
| comments = this.getAllCommentsForFile(file); | |
| } else { | |
| comments = this._commentObjToArray<Comment>( | |
| this.getAllPublishedComments(file.patchNum) | |
| ); | |
| } | |
| let threads = createCommentThreads(comments); | |
| if (ignorePatchsetLevelComments) | |
| threads = threads.filter(thread => !isPatchsetLevel(thread)); | |
| return threads; | |
| } | |
| /** | |
| * 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; | |
| } | |
| computeDraftCountForFile(patchRange?: PatchRange, file?: NormalizedFileInfo) { | |
| if (patchRange === undefined || file === undefined) { | |
| return 0; | |
| } | |
| const getCommentForPath = (path?: string) => { | |
| if (!path) return 0; | |
| return ( | |
| this.computeDraftCount({ | |
| patchNum: patchRange.basePatchNum, | |
| path, | |
| }) + | |
| this.computeDraftCount({ | |
| patchNum: patchRange.patchNum, | |
| path, | |
| }) + | |
| this.computePortedDraftCount( | |
| { | |
| patchNum: patchRange.patchNum, | |
| basePatchNum: patchRange.basePatchNum, | |
| }, | |
| path | |
| ) | |
| ); | |
| }; | |
| return getCommentForPath(file.__path) + getCommentForPath(file.old_path); | |
| } | |
| computeCommentsThreads( | |
| patchRange: PatchRange, | |
| path: string, | |
| changeFileInfo?: FileInfo | |
| ) { | |
| const threads = this.getThreadsBySideForFile({path}, patchRange); | |
| if (changeFileInfo?.old_path) { | |
| threads.push( | |
| ...this.getThreadsBySideForFile( | |
| {path: changeFileInfo.old_path}, | |
| patchRange | |
| ) | |
| ); | |
| } | |
| return threads; | |
| } | |
| /** | |
| * @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.computeCommentsThreads( | |
| patchRange, | |
| path, | |
| changeFileInfo | |
| ); | |
| 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, | |
| ignorePatchsetLevelComments?: boolean | |
| ) { | |
| 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); | |
| let unresolvedThreads = threads.filter(isUnresolved); | |
| if (ignorePatchsetLevelComments) | |
| unresolvedThreads = unresolvedThreads.filter( | |
| thread => !isPatchsetLevel(thread) | |
| ); | |
| return unresolvedThreads.length; | |
| } | |
| getAllThreadsForChange() { | |
| const comments = this._commentObjToArray(this.getAllComments(true)); | |
| return createCommentThreads(comments); | |
| } | |
| } |