|  | /** | 
|  | * @license | 
|  | * Copyright (C) 2020 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 { | 
|  | CommentBasics, | 
|  | CommentInfo, | 
|  | PatchSetNum, | 
|  | RobotCommentInfo, | 
|  | Timestamp, | 
|  | UrlEncodedCommentId, | 
|  | CommentRange, | 
|  | PatchRange, | 
|  | ParentPatchSetNum, | 
|  | ContextLine, | 
|  | BasePatchSetNum, | 
|  | RevisionPatchSetNum, | 
|  | AccountInfo, | 
|  | AccountDetailInfo, | 
|  | } from '../types/common'; | 
|  | import {CommentSide, SpecialFilePath} from '../constants/constants'; | 
|  | import {parseDate} from './date-util'; | 
|  | import {CommentIdToCommentThreadMap} from '../elements/diff/gr-comment-api/gr-comment-api'; | 
|  | import {isMergeParent, getParentIndex} from './patch-set-util'; | 
|  | import {DiffInfo} from '../types/diff'; | 
|  | import {LineNumber} from '../api/diff'; | 
|  |  | 
|  | export interface DraftCommentProps { | 
|  | // This must be true for all drafts. Drafts received from the backend will be | 
|  | // modified immediately with __draft:true before allowing them to get into | 
|  | // the application state. | 
|  | __draft: boolean; | 
|  | } | 
|  |  | 
|  | export interface UnsavedCommentProps { | 
|  | // This must be true for all unsaved comment drafts. An unsaved draft is | 
|  | // always just local to a comment component like <gr-comment> or | 
|  | // <gr-comment-thread>. Unsaved drafts will never appear in the application | 
|  | // state. | 
|  | __unsaved: boolean; | 
|  | } | 
|  |  | 
|  | export type DraftInfo = CommentInfo & DraftCommentProps; | 
|  |  | 
|  | export type UnsavedInfo = CommentBasics & UnsavedCommentProps; | 
|  |  | 
|  | export type Comment = UnsavedInfo | DraftInfo | CommentInfo | RobotCommentInfo; | 
|  |  | 
|  | export type CommentMap = {[path: string]: boolean}; | 
|  |  | 
|  | export function isRobot<T extends CommentBasics>( | 
|  | x: T | DraftInfo | RobotCommentInfo | undefined | 
|  | ): x is RobotCommentInfo { | 
|  | return !!x && !!(x as RobotCommentInfo).robot_id; | 
|  | } | 
|  |  | 
|  | export function isDraft<T extends CommentBasics>( | 
|  | x: T | DraftInfo | undefined | 
|  | ): x is DraftInfo { | 
|  | return !!x && !!(x as DraftInfo).__draft; | 
|  | } | 
|  |  | 
|  | export function isUnsaved<T extends CommentBasics>( | 
|  | x: T | UnsavedInfo | undefined | 
|  | ): x is UnsavedInfo { | 
|  | return !!x && !!(x as UnsavedInfo).__unsaved; | 
|  | } | 
|  |  | 
|  | export function isDraftOrUnsaved<T extends CommentBasics>( | 
|  | x: T | DraftInfo | UnsavedInfo | undefined | 
|  | ): x is UnsavedInfo | DraftInfo { | 
|  | return isDraft(x) || isUnsaved(x); | 
|  | } | 
|  |  | 
|  | interface SortableComment { | 
|  | updated: Timestamp; | 
|  | id: UrlEncodedCommentId; | 
|  | } | 
|  |  | 
|  | export function sortComments<T extends SortableComment>(comments: T[]): T[] { | 
|  | return comments.slice(0).sort((c1, c2) => { | 
|  | const d1 = isDraft(c1); | 
|  | const d2 = isDraft(c2); | 
|  | if (d1 !== d2) return d1 ? 1 : -1; | 
|  |  | 
|  | const date1 = parseDate(c1.updated); | 
|  | const date2 = parseDate(c2.updated); | 
|  | const dateDiff = date1!.valueOf() - date2!.valueOf(); | 
|  | if (dateDiff !== 0) return dateDiff; | 
|  |  | 
|  | const id1 = c1.id; | 
|  | const id2 = c2.id; | 
|  | return id1.localeCompare(id2); | 
|  | }); | 
|  | } | 
|  |  | 
|  | export function createUnsavedComment(thread: CommentThread): UnsavedInfo { | 
|  | return { | 
|  | path: thread.path, | 
|  | patch_set: thread.patchNum, | 
|  | side: thread.commentSide ?? CommentSide.REVISION, | 
|  | line: typeof thread.line === 'number' ? thread.line : undefined, | 
|  | range: thread.range, | 
|  | parent: thread.mergeParentNum, | 
|  | message: '', | 
|  | unresolved: true, | 
|  | __unsaved: true, | 
|  | }; | 
|  | } | 
|  |  | 
|  | export function createUnsavedReply( | 
|  | replyingTo: CommentInfo, | 
|  | message: string, | 
|  | unresolved: boolean | 
|  | ): UnsavedInfo { | 
|  | return { | 
|  | path: replyingTo.path, | 
|  | patch_set: replyingTo.patch_set, | 
|  | side: replyingTo.side, | 
|  | line: replyingTo.line, | 
|  | range: replyingTo.range, | 
|  | parent: replyingTo.parent, | 
|  | in_reply_to: replyingTo.id, | 
|  | message, | 
|  | unresolved, | 
|  | __unsaved: true, | 
|  | }; | 
|  | } | 
|  |  | 
|  | export function createCommentThreads(comments: CommentInfo[]) { | 
|  | const sortedComments = sortComments(comments); | 
|  | const threads: CommentThread[] = []; | 
|  | const idThreadMap: CommentIdToCommentThreadMap = {}; | 
|  | for (const comment of sortedComments) { | 
|  | // thread and append to it. | 
|  | if (comment.in_reply_to) { | 
|  | const thread = idThreadMap[comment.in_reply_to]; | 
|  | if (thread) { | 
|  | thread.comments.push(comment); | 
|  | if (comment.id) idThreadMap[comment.id] = thread; | 
|  | continue; | 
|  | } | 
|  | } | 
|  |  | 
|  | // Otherwise, this comment starts its own thread. | 
|  | if (!comment.path) { | 
|  | throw new Error('Comment missing required "path".'); | 
|  | } | 
|  | const newThread: CommentThread = { | 
|  | comments: [comment], | 
|  | patchNum: comment.patch_set, | 
|  | commentSide: comment.side ?? CommentSide.REVISION, | 
|  | mergeParentNum: comment.parent, | 
|  | path: comment.path, | 
|  | line: comment.line, | 
|  | range: comment.range, | 
|  | rootId: comment.id, | 
|  | }; | 
|  | if (!comment.line && !comment.range) { | 
|  | newThread.line = 'FILE'; | 
|  | } | 
|  | threads.push(newThread); | 
|  | if (comment.id) idThreadMap[comment.id] = newThread; | 
|  | } | 
|  | return threads; | 
|  | } | 
|  |  | 
|  | export interface CommentThread { | 
|  | /** | 
|  | * This can only contain at most one draft. And if so, then it is the last | 
|  | * comment in this list. This must not contain unsaved drafts. | 
|  | */ | 
|  | comments: Array<CommentInfo | DraftInfo | RobotCommentInfo>; | 
|  | /** | 
|  | * Identical to the id of the first comment. If this is undefined, then the | 
|  | * thread only contains an unsaved draft. | 
|  | */ | 
|  | rootId?: UrlEncodedCommentId; | 
|  | /** | 
|  | * Note that all location information is typically identical to that of the | 
|  | * first comment, but not for ported comments! | 
|  | */ | 
|  | path: string; | 
|  | commentSide: CommentSide; | 
|  | /* mergeParentNum is the merge parent number only valid for merge commits | 
|  | when commentSide is PARENT. | 
|  | mergeParentNum is undefined for auto merge commits | 
|  | Same as `parent` in CommentInfo. | 
|  | */ | 
|  | mergeParentNum?: number; | 
|  | patchNum?: PatchSetNum; | 
|  | /* Different from CommentInfo, which just keeps the line undefined for | 
|  | FILE comments. */ | 
|  | line?: LineNumber; | 
|  | range?: CommentRange; | 
|  | /** | 
|  | * Was the thread ported over from its original location to a newer patchset? | 
|  | * If yes, then the location information above contains the ported location, | 
|  | * but the comments still have the original location set. | 
|  | */ | 
|  | ported?: boolean; | 
|  | /** | 
|  | * Only relevant when ported:true. Means that no ported range could be | 
|  | * computed. `line` and `range` can be undefined then. | 
|  | */ | 
|  | rangeInfoLost?: boolean; | 
|  | } | 
|  |  | 
|  | export function equalLocation(t1?: CommentThread, t2?: CommentThread) { | 
|  | if (t1 === t2) return true; | 
|  | if (t1 === undefined || t2 === undefined) return false; | 
|  | return ( | 
|  | t1.path === t2.path && | 
|  | t1.patchNum === t2.patchNum && | 
|  | t1.commentSide === t2.commentSide && | 
|  | t1.line === t2.line && | 
|  | t1.range?.start_line === t2.range?.start_line && | 
|  | t1.range?.start_character === t2.range?.start_character && | 
|  | t1.range?.end_line === t2.range?.end_line && | 
|  | t1.range?.end_character === t2.range?.end_character | 
|  | ); | 
|  | } | 
|  |  | 
|  | export function getLastComment(thread: CommentThread): CommentInfo | undefined { | 
|  | const len = thread.comments.length; | 
|  | return thread.comments[len - 1]; | 
|  | } | 
|  |  | 
|  | export function getLastPublishedComment( | 
|  | thread: CommentThread | 
|  | ): CommentInfo | undefined { | 
|  | const publishedComments = thread.comments.filter(c => !isDraftOrUnsaved(c)); | 
|  | const len = publishedComments.length; | 
|  | return publishedComments[len - 1]; | 
|  | } | 
|  |  | 
|  | export function getFirstComment( | 
|  | thread: CommentThread | 
|  | ): CommentInfo | undefined { | 
|  | return thread.comments[0]; | 
|  | } | 
|  |  | 
|  | export function countComments(thread: CommentThread) { | 
|  | return thread.comments.length; | 
|  | } | 
|  |  | 
|  | export function isPatchsetLevel(thread: CommentThread): boolean { | 
|  | return thread.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS; | 
|  | } | 
|  |  | 
|  | export function isUnresolved(thread: CommentThread): boolean { | 
|  | return !isResolved(thread); | 
|  | } | 
|  |  | 
|  | export function isResolved(thread: CommentThread): boolean { | 
|  | const lastUnresolved = getLastComment(thread)?.unresolved; | 
|  | return !lastUnresolved ?? false; | 
|  | } | 
|  |  | 
|  | export function isDraftThread(thread: CommentThread): boolean { | 
|  | return isDraft(getLastComment(thread)); | 
|  | } | 
|  |  | 
|  | export function isRobotThread(thread: CommentThread): boolean { | 
|  | return isRobot(getFirstComment(thread)); | 
|  | } | 
|  |  | 
|  | export function hasHumanReply(thread: CommentThread): boolean { | 
|  | return countComments(thread) > 1 && !isRobot(getLastComment(thread)); | 
|  | } | 
|  |  | 
|  | export function lastUpdated(thread: CommentThread): Date | undefined { | 
|  | // We don't want to re-sort comments when you save a draft reply, so | 
|  | // we stick to the timestampe of the last *published* comment. | 
|  | const lastUpdated = | 
|  | getLastPublishedComment(thread)?.updated ?? getLastComment(thread)?.updated; | 
|  | return lastUpdated !== undefined ? parseDate(lastUpdated) : undefined; | 
|  | } | 
|  | /** | 
|  | * Whether the given comment should be included in the base side of the | 
|  | * given patch range. | 
|  | */ | 
|  | export function isInBaseOfPatchRange( | 
|  | comment: { | 
|  | patch_set?: PatchSetNum; | 
|  | side?: CommentSide; | 
|  | parent?: number; | 
|  | }, | 
|  | range: PatchRange | 
|  | ) { | 
|  | // 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 === CommentSide.PARENT) { | 
|  | return ( | 
|  | isMergeParent(range.basePatchNum) && | 
|  | comment.parent === getParentIndex(range.basePatchNum) | 
|  | ); | 
|  | } | 
|  |  | 
|  | // If the base of the range is the parent of the patch: | 
|  | if ( | 
|  | range.basePatchNum === ParentPatchSetNum && | 
|  | comment.side === CommentSide.PARENT && | 
|  | comment.patch_set === range.patchNum | 
|  | ) { | 
|  | return true; | 
|  | } | 
|  | // If the base of the range is not the parent of the patch: | 
|  | return ( | 
|  | range.basePatchNum !== ParentPatchSetNum && | 
|  | comment.side !== CommentSide.PARENT && | 
|  | comment.patch_set === range.basePatchNum | 
|  | ); | 
|  | } | 
|  |  | 
|  | /** | 
|  | * Whether the given comment should be included in the revision side of the | 
|  | * given patch range. | 
|  | */ | 
|  | export function isInRevisionOfPatchRange( | 
|  | comment: { | 
|  | patch_set?: PatchSetNum; | 
|  | side?: CommentSide; | 
|  | }, | 
|  | range: PatchRange | 
|  | ) { | 
|  | return ( | 
|  | comment.side !== CommentSide.PARENT && comment.patch_set === range.patchNum | 
|  | ); | 
|  | } | 
|  |  | 
|  | /** | 
|  | * Whether the given comment should be included in the given patch range. | 
|  | */ | 
|  | export function isInPatchRange( | 
|  | comment: CommentBasics, | 
|  | range: PatchRange | 
|  | ): boolean { | 
|  | return ( | 
|  | isInBaseOfPatchRange(comment, range) || | 
|  | isInRevisionOfPatchRange(comment, range) | 
|  | ); | 
|  | } | 
|  |  | 
|  | export function getPatchRangeForCommentUrl( | 
|  | comment: Comment, | 
|  | latestPatchNum: RevisionPatchSetNum | 
|  | ) { | 
|  | if (!comment.patch_set) throw new Error('Missing comment.patch_set'); | 
|  |  | 
|  | // TODO(dhruvsri): Add handling for comment left on parents of merge commits | 
|  | if (comment.side === CommentSide.PARENT) { | 
|  | if (comment.patch_set === ParentPatchSetNum) | 
|  | throw new Error('comment.patch_set cannot be PARENT'); | 
|  | return { | 
|  | patchNum: comment.patch_set as RevisionPatchSetNum, | 
|  | basePatchNum: ParentPatchSetNum, | 
|  | }; | 
|  | } else if (latestPatchNum === comment.patch_set) { | 
|  | return { | 
|  | patchNum: latestPatchNum, | 
|  | basePatchNum: ParentPatchSetNum, | 
|  | }; | 
|  | } else { | 
|  | return { | 
|  | patchNum: latestPatchNum as RevisionPatchSetNum, | 
|  | basePatchNum: comment.patch_set as BasePatchSetNum, | 
|  | }; | 
|  | } | 
|  | } | 
|  |  | 
|  | export function computeDiffFromContext( | 
|  | context: ContextLine[], | 
|  | path: string, | 
|  | content_type?: string | 
|  | ) { | 
|  | // do not render more than 20 lines of context | 
|  | context = context.slice(0, 20); | 
|  | const diff: DiffInfo = { | 
|  | meta_a: { | 
|  | name: '', | 
|  | content_type: '', | 
|  | lines: 0, | 
|  | web_links: [], | 
|  | }, | 
|  | meta_b: { | 
|  | name: path, | 
|  | content_type: content_type || '', | 
|  | lines: context.length + context?.[0].line_number, | 
|  | web_links: [], | 
|  | }, | 
|  | change_type: 'MODIFIED', | 
|  | intraline_status: 'OK', | 
|  | diff_header: [], | 
|  | content: [ | 
|  | { | 
|  | skip: context[0].line_number - 1, | 
|  | }, | 
|  | { | 
|  | b: context.map(line => line.context_line), | 
|  | }, | 
|  | ], | 
|  | }; | 
|  | return diff; | 
|  | } | 
|  |  | 
|  | export function getCommentAuthors( | 
|  | threads?: CommentThread[], | 
|  | user?: AccountDetailInfo | 
|  | ) { | 
|  | if (!threads || !user) return []; | 
|  | const ids = new Set(); | 
|  | const authors: AccountInfo[] = []; | 
|  | threads.forEach(t => | 
|  | t.comments.forEach(c => { | 
|  | if (isDraft(c) && !ids.has(user._account_id)) { | 
|  | ids.add(user._account_id); | 
|  | authors.push(user); | 
|  | return; | 
|  | } | 
|  | if (c.author && !ids.has(c.author._account_id)) { | 
|  | ids.add(c.author._account_id); | 
|  | authors.push(c.author); | 
|  | } | 
|  | }) | 
|  | ); | 
|  | return authors; | 
|  | } | 
|  |  | 
|  | /** | 
|  | * Add path info to every comment as CommentInfo returned from server does not | 
|  | * have that. | 
|  | */ | 
|  | export function 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)) { | 
|  | updatedComments[filePath] = (comments[filePath] || []).map(comment => { | 
|  | return {...comment, path: filePath}; | 
|  | }); | 
|  | } | 
|  | return updatedComments; | 
|  | } | 
|  |  | 
|  | /** | 
|  | * Add __draft:true to all drafts returned from server so that they can be told | 
|  | * apart from published comments easily. | 
|  | */ | 
|  | export function addDraftProp( | 
|  | draftsByPath: {[path: string]: CommentInfo[]} = {} | 
|  | ) { | 
|  | const updated: {[path: string]: DraftInfo[]} = {}; | 
|  | for (const filePath of Object.keys(draftsByPath)) { | 
|  | updated[filePath] = (draftsByPath[filePath] ?? []).map(draft => { | 
|  | return {...draft, __draft: true}; | 
|  | }); | 
|  | } | 
|  | return updated; | 
|  | } | 
|  |  | 
|  | export function reportingDetails(comment: CommentBasics) { | 
|  | return { | 
|  | id: comment?.id, | 
|  | message_length: comment?.message?.trim().length, | 
|  | in_reply_to: comment?.in_reply_to, | 
|  | unresolved: comment?.unresolved, | 
|  | path_length: comment?.path?.length, | 
|  | line: comment?.range?.start_line ?? comment?.line, | 
|  | unsaved: isUnsaved(comment), | 
|  | }; | 
|  | } |